diff --git a/blight-assets/src/main/resources/MatDefs/CloudDome.j3md b/blight-assets/src/main/resources/MatDefs/CloudDome.j3md index fcdfceb..cce41c0 100644 --- a/blight-assets/src/main/resources/MatDefs/CloudDome.j3md +++ b/blight-assets/src/main/resources/MatDefs/CloudDome.j3md @@ -4,6 +4,7 @@ MaterialDef CloudDome { Vector2 CloudOffset : 0.0 0.0 Float CloudCover : 0.0 Color CloudColor : 1.0 1.0 1.0 1.0 + Vector3 SunsetGlow : 0.0 0.0 0.0 } Technique { diff --git a/blight-assets/src/main/resources/Shaders/skies/clouds/CloudDome.frag b/blight-assets/src/main/resources/Shaders/skies/clouds/CloudDome.frag index d9f67eb..1ecc2ab 100644 --- a/blight-assets/src/main/resources/Shaders/skies/clouds/CloudDome.frag +++ b/blight-assets/src/main/resources/Shaders/skies/clouds/CloudDome.frag @@ -1,6 +1,7 @@ uniform vec2 m_CloudOffset; uniform float m_CloudCover; uniform vec4 m_CloudColor; +uniform vec3 m_SunsetGlow; // additiver Rot-Orange-Ton für Wolkenunterseite bei tiefem Sonnenstand in vec3 vDir; out vec4 outColor; @@ -49,5 +50,8 @@ void main() { cloud *= hFade; - outColor = vec4(m_CloudColor.rgb, cloud * m_CloudColor.a); + // Abendrot-Glut auf Unterseite: overhead-Wolken (dir.y hoch) leuchten stärker rot + float glowWeight = smoothstep(0.0, 0.4, dir.y); + vec3 cloudRgb = m_CloudColor.rgb + m_SunsetGlow * glowWeight; + outColor = vec4(min(cloudRgb, vec3(1.0)), cloud * m_CloudColor.a); } diff --git a/blight-assets/src/main/resources/audio/music/01-Risen Title Theme.ogg b/blight-assets/src/main/resources/audio/music/01-Risen Title Theme.ogg new file mode 100644 index 0000000..4abb0f2 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/01-Risen Title Theme.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/02-The Beach.ogg b/blight-assets/src/main/resources/audio/music/02-The Beach.ogg new file mode 100644 index 0000000..48ca673 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/02-The Beach.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/03-The Beach-Fight.ogg b/blight-assets/src/main/resources/audio/music/03-The Beach-Fight.ogg new file mode 100644 index 0000000..bbac031 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/03-The Beach-Fight.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/04-The Island.ogg b/blight-assets/src/main/resources/audio/music/04-The Island.ogg new file mode 100644 index 0000000..15bfb92 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/04-The Island.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/05-The Island At Night.ogg b/blight-assets/src/main/resources/audio/music/05-The Island At Night.ogg new file mode 100644 index 0000000..9b73f31 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/05-The Island At Night.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/06-The Harbor City.ogg b/blight-assets/src/main/resources/audio/music/06-The Harbor City.ogg new file mode 100644 index 0000000..0efc1d5 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/06-The Harbor City.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/07-The Harbor City At Night.ogg b/blight-assets/src/main/resources/audio/music/07-The Harbor City At Night.ogg new file mode 100644 index 0000000..9d41322 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/07-The Harbor City At Night.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/08-The Volcanic Fortress.ogg b/blight-assets/src/main/resources/audio/music/08-The Volcanic Fortress.ogg new file mode 100644 index 0000000..cbd6e4f Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/08-The Volcanic Fortress.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/09-The Volcanic Fortress-Fight.ogg b/blight-assets/src/main/resources/audio/music/09-The Volcanic Fortress-Fight.ogg new file mode 100644 index 0000000..0ff1320 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/09-The Volcanic Fortress-Fight.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/10-The Holy Fire.ogg b/blight-assets/src/main/resources/audio/music/10-The Holy Fire.ogg new file mode 100644 index 0000000..ab6f14b Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/10-The Holy Fire.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/11-The Don`s Camp.ogg b/blight-assets/src/main/resources/audio/music/11-The Don`s Camp.ogg new file mode 100644 index 0000000..6121c17 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/11-The Don`s Camp.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/12-The Don`s Camp-Fight.ogg b/blight-assets/src/main/resources/audio/music/12-The Don`s Camp-Fight.ogg new file mode 100644 index 0000000..ba7d174 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/12-The Don`s Camp-Fight.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/13-The Great Swamp.ogg b/blight-assets/src/main/resources/audio/music/13-The Great Swamp.ogg new file mode 100644 index 0000000..372ab6c Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/13-The Great Swamp.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/14-The Great Swamp-Fight.ogg b/blight-assets/src/main/resources/audio/music/14-The Great Swamp-Fight.ogg new file mode 100644 index 0000000..710cbcc Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/14-The Great Swamp-Fight.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/15-The Gyrger Island.ogg b/blight-assets/src/main/resources/audio/music/15-The Gyrger Island.ogg new file mode 100644 index 0000000..1cd775b Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/15-The Gyrger Island.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/16-The Gyrger Island-Fight.ogg b/blight-assets/src/main/resources/audio/music/16-The Gyrger Island-Fight.ogg new file mode 100644 index 0000000..f3d592b Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/16-The Gyrger Island-Fight.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/17-The Dungeons-Natural Caves.ogg b/blight-assets/src/main/resources/audio/music/17-The Dungeons-Natural Caves.ogg new file mode 100644 index 0000000..abd7640 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/17-The Dungeons-Natural Caves.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/18-The Dungeons-Ruins.ogg b/blight-assets/src/main/resources/audio/music/18-The Dungeons-Ruins.ogg new file mode 100644 index 0000000..378213e Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/18-The Dungeons-Ruins.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/19-The Dungeons-Who Built This _.ogg b/blight-assets/src/main/resources/audio/music/19-The Dungeons-Who Built This _.ogg new file mode 100644 index 0000000..77b73e2 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/19-The Dungeons-Who Built This _.ogg differ diff --git a/blight-assets/src/main/resources/audio/music/20-Showdown.ogg b/blight-assets/src/main/resources/audio/music/20-Showdown.ogg new file mode 100644 index 0000000..ab7bf65 Binary files /dev/null and b/blight-assets/src/main/resources/audio/music/20-Showdown.ogg differ diff --git a/blight-common/src/main/java/de/blight/common/AreaDefinition.java b/blight-common/src/main/java/de/blight/common/AreaDefinition.java new file mode 100644 index 0000000..478f70f --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/AreaDefinition.java @@ -0,0 +1,10 @@ +package de.blight.common; + +public record AreaDefinition( + String id, + String dayTrack, + String nightTrack, + String combatTrack +) { + public String nameKey() { return id.isBlank() ? "" : id + ".name"; } +} diff --git a/blight-common/src/main/java/de/blight/common/AreaDefinitionIO.java b/blight-common/src/main/java/de/blight/common/AreaDefinitionIO.java new file mode 100644 index 0000000..87297fa --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/AreaDefinitionIO.java @@ -0,0 +1,47 @@ +package de.blight.common; + +import java.io.*; +import java.nio.file.*; +import java.util.*; + +public final class AreaDefinitionIO { + + private AreaDefinitionIO() {} + + public static Path getPath() { + return MapIO.getMapPath().resolveSibling("blight_area_definitions.bad"); + } + + public static void save(List defs) throws IOException { + Path p = getPath(); + Files.createDirectories(p.getParent()); + try (BufferedWriter w = Files.newBufferedWriter(p)) { + w.write("# id\tdayTrack\tnightTrack\tcombatTrack"); + w.newLine(); + for (AreaDefinition d : defs) { + w.write(d.id()); + w.write('\t'); + w.write(d.dayTrack()); + w.write('\t'); + w.write(d.nightTrack()); + w.write('\t'); + w.write(d.combatTrack()); + w.newLine(); + } + } + } + + public static List load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List list = new ArrayList<>(); + for (String line : Files.readAllLines(p)) { + line = line.stripLeading(); + if (line.isEmpty() || line.startsWith("#")) continue; + String[] f = line.split("\t", -1); + if (f.length < 4) continue; + list.add(new AreaDefinition(f[0].strip(), f[1].strip(), f[2].strip(), f[3].strip())); + } + return list; + } +} diff --git a/blight-common/src/main/java/de/blight/common/AreaIO.java b/blight-common/src/main/java/de/blight/common/AreaIO.java index cbf530d..2249d95 100644 --- a/blight-common/src/main/java/de/blight/common/AreaIO.java +++ b/blight-common/src/main/java/de/blight/common/AreaIO.java @@ -1,5 +1,8 @@ package de.blight.common; +import de.blight.common.model.trigger.Trigger; +import de.blight.common.model.trigger.TriggerIO; + import java.io.*; import java.nio.file.*; import java.util.*; @@ -16,18 +19,14 @@ public final class AreaIO { Path p = getPath(); Files.createDirectories(p.getParent()); try (BufferedWriter w = Files.newBufferedWriter(p)) { - w.write("# polygon\tnameId\tdayTrack\tnightTrack\tcombatTrack"); + w.write("# polygon\tareaId\ttriggersJson"); w.newLine(); for (PlacedArea a : areas) { w.write(SoundAreaIO.encodePolygon(a.pointsX(), a.pointsZ())); w.write('\t'); - w.write(a.nameId()); + w.write(a.areaId()); w.write('\t'); - w.write(a.dayTrack()); - w.write('\t'); - w.write(a.nightTrack()); - w.write('\t'); - w.write(a.combatTrack()); + w.write(TriggerIO.serializeList(a.triggers())); w.newLine(); } } @@ -38,14 +37,16 @@ public final class AreaIO { if (!Files.exists(p)) return List.of(); List list = new ArrayList<>(); for (String line : Files.readAllLines(p)) { - line = line.strip(); + line = line.stripLeading(); if (line.isEmpty() || line.startsWith("#")) continue; String[] f = line.split("\t", -1); - if (f.length < 5) continue; + if (f.length < 1) continue; try { float[][] pts = SoundAreaIO.decodePolygon(f[0]); if (pts[0].length < 3) continue; - list.add(new PlacedArea(pts[0], pts[1], f[1], f[2], f[3], f[4])); + String areaId = f.length > 1 ? f[1].strip() : ""; + List triggers = f.length > 2 ? TriggerIO.deserializeList(f[2]) : new ArrayList<>(); + list.add(new PlacedArea(pts[0], pts[1], areaId, triggers)); } catch (Exception ignored) {} } return list; diff --git a/blight-common/src/main/java/de/blight/common/PlacedArea.java b/blight-common/src/main/java/de/blight/common/PlacedArea.java index 1c0a0a2..c7c7cca 100644 --- a/blight-common/src/main/java/de/blight/common/PlacedArea.java +++ b/blight-common/src/main/java/de/blight/common/PlacedArea.java @@ -1,10 +1,16 @@ package de.blight.common; +import de.blight.common.model.trigger.Trigger; + +import java.util.List; + public record PlacedArea( float[] pointsX, float[] pointsZ, - String nameId, - String dayTrack, - String nightTrack, - String combatTrack -) {} + String areaId, + List triggers +) { + public PlacedArea(float[] pointsX, float[] pointsZ, String areaId) { + this(pointsX, pointsZ, areaId, List.of()); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/Location.java b/blight-common/src/main/java/de/blight/common/model/Location.java index 915d3cc..2a4696a 100644 --- a/blight-common/src/main/java/de/blight/common/model/Location.java +++ b/blight-common/src/main/java/de/blight/common/model/Location.java @@ -28,6 +28,6 @@ public class Location { public void entered(MainCharacter character) { triggers.stream() .filter(t -> t.isTriggarable(character)) - .forEach(t -> t.trigger(character)); + .forEach(t -> t.fire(character)); } } diff --git a/blight-common/src/main/java/de/blight/common/model/MainCharacter.java b/blight-common/src/main/java/de/blight/common/model/MainCharacter.java index f6977a6..06e647b 100644 --- a/blight-common/src/main/java/de/blight/common/model/MainCharacter.java +++ b/blight-common/src/main/java/de/blight/common/model/MainCharacter.java @@ -2,10 +2,13 @@ package de.blight.common.model; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.Set; +import java.util.UUID; import de.blight.common.model.quests.Quest; import lombok.AccessLevel; @@ -44,6 +47,18 @@ public class MainCharacter extends GameCharacter { /** Gespielte Monolog-IDs – wird serialisiert, damit jeder Monolog nur einmal abgespielt wird. */ private Set playedMonologueIds = new HashSet<>(); + /** IDs bereits gefeuerter Trigger – verhindert Mehrfach-Auslösung. */ + private Set firedTriggerIds = new HashSet<>(); + + /** Bereits betretene Zonen (für FirstTimeCondition). */ + private Set visitedZoneIds = new HashSet<>(); + + /** Freie Spielvariablen (key→value) für GameVariableCondition. */ + private Map gameVariables = new HashMap<>(); + + /** Fraktionen, denen der Spieler angehört (für FactionMemberCondition). */ + private Set memberFractionIds = new HashSet<>(); + @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private List listeners = new ArrayList(); @@ -52,6 +67,11 @@ public class MainCharacter extends GameCharacter { @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private transient Queue pendingMonologues = new ArrayDeque<>(); + + /** Warteschlange für per Trigger ausgelöste NPC-Dialoge – wird NICHT serialisiert. */ + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient Queue pendingDialogNpcIds = new ArrayDeque<>(); public void handleDialogOption(DialogOption option) { if (option.getRequiredItem() != null) { @@ -146,6 +166,37 @@ public class MainCharacter extends GameCharacter { return pendingMonologues.poll(); } + /** Stellt einen per Trigger ausgelösten NPC-Dialog in die Warteschlange. */ + public void queueDialog(String npcId) { + if (pendingDialogNpcIds == null) pendingDialogNpcIds = new ArrayDeque<>(); + pendingDialogNpcIds.offer(npcId); + } + + /** Gibt den nächsten ausstehenden Dialog-NPC zurück oder null. */ + public String pollPendingDialog() { + if (pendingDialogNpcIds == null) return null; + return pendingDialogNpcIds.poll(); + } + + public boolean isQuestCompleted(String questId) { + return completedQuests != null && completedQuests.stream() + .anyMatch(q -> questId.equals(q.getQuestId())); + } + + public boolean isQuestAccepted(String questId) { + return openQuests != null && openQuests.stream() + .anyMatch(q -> questId.equals(q.getQuestId())); + } + + public boolean isQuestRejected(String questId) { + return abortedQuests != null && abortedQuests.stream() + .anyMatch(q -> questId.equals(q.getQuestId())); + } + + public boolean isFactionMember(UUID fractionId) { + return memberFractionIds != null && memberFractionIds.contains(fractionId); + } + /** Wendet die Quest-Folgen eines Monologs an. */ public void handleMonologue(Monologue m) { if (m.getRecievesQuest() != null) startQuest(m.getRecievesQuest()); diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/ChapterCondition.java b/blight-common/src/main/java/de/blight/common/model/trigger/ChapterCondition.java new file mode 100644 index 0000000..98b0da6 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/ChapterCondition.java @@ -0,0 +1,16 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ChapterCondition extends Condition { + private int minChapter; + + @Override + public boolean evaluate(MainCharacter character) { + return character.getChapter() >= minChapter; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/Condition.java b/blight-common/src/main/java/de/blight/common/model/trigger/Condition.java new file mode 100644 index 0000000..f18a5fe --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/Condition.java @@ -0,0 +1,7 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; + +public abstract class Condition { + public abstract boolean evaluate(MainCharacter character); +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/ConditionMode.java b/blight-common/src/main/java/de/blight/common/model/trigger/ConditionMode.java new file mode 100644 index 0000000..74b6006 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/ConditionMode.java @@ -0,0 +1,3 @@ +package de.blight.common.model.trigger; + +public enum ConditionMode { ALL, ANY } diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/FactionMemberCondition.java b/blight-common/src/main/java/de/blight/common/model/trigger/FactionMemberCondition.java new file mode 100644 index 0000000..3a72f08 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/FactionMemberCondition.java @@ -0,0 +1,19 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class FactionMemberCondition extends Condition { + private UUID fractionId; + + @Override + public boolean evaluate(MainCharacter character) { + if (fractionId == null) return false; + return character.isFactionMember(fractionId); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/FirstTimeCondition.java b/blight-common/src/main/java/de/blight/common/model/trigger/FirstTimeCondition.java new file mode 100644 index 0000000..115e348 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/FirstTimeCondition.java @@ -0,0 +1,16 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class FirstTimeCondition extends Condition { + private String zoneId; + + @Override + public boolean evaluate(MainCharacter character) { + return zoneId != null && !character.getVisitedZoneIds().contains(zoneId); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/GameVariableCondition.java b/blight-common/src/main/java/de/blight/common/model/trigger/GameVariableCondition.java new file mode 100644 index 0000000..9d7f2ea --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/GameVariableCondition.java @@ -0,0 +1,20 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class GameVariableCondition extends Condition { + private String key; + private String value; + + @Override + public boolean evaluate(MainCharacter character) { + if (key == null || key.isBlank()) return false; + String actual = character.getGameVariables().get(key); + if (value == null || value.isBlank()) return actual != null; + return value.equals(actual); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/NpcDialogTrigger.java b/blight-common/src/main/java/de/blight/common/model/trigger/NpcDialogTrigger.java new file mode 100644 index 0000000..5b7e97d --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/NpcDialogTrigger.java @@ -0,0 +1,23 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class NpcDialogTrigger extends Trigger { + private String npcId; + + @Override + public boolean isTriggarableDelegate(MainCharacter character) { + return npcId != null && !npcId.isBlank(); + } + + @Override + public void trigger(MainCharacter character) { + if (npcId != null && !npcId.isBlank()) { + character.queueDialog(npcId); + } + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/QuestAcceptedCondition.java b/blight-common/src/main/java/de/blight/common/model/trigger/QuestAcceptedCondition.java new file mode 100644 index 0000000..2113195 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/QuestAcceptedCondition.java @@ -0,0 +1,16 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class QuestAcceptedCondition extends Condition { + private String questId; + + @Override + public boolean evaluate(MainCharacter character) { + return questId != null && character.isQuestAccepted(questId); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/QuestCompletedCondition.java b/blight-common/src/main/java/de/blight/common/model/trigger/QuestCompletedCondition.java new file mode 100644 index 0000000..8b575c4 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/QuestCompletedCondition.java @@ -0,0 +1,16 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class QuestCompletedCondition extends Condition { + private String questId; + + @Override + public boolean evaluate(MainCharacter character) { + return questId != null && character.isQuestCompleted(questId); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/QuestRejectedCondition.java b/blight-common/src/main/java/de/blight/common/model/trigger/QuestRejectedCondition.java new file mode 100644 index 0000000..93684b4 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/QuestRejectedCondition.java @@ -0,0 +1,16 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class QuestRejectedCondition extends Condition { + private String questId; + + @Override + public boolean evaluate(MainCharacter character) { + return questId != null && character.isQuestRejected(questId); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/Trigger.java b/blight-common/src/main/java/de/blight/common/model/trigger/Trigger.java index 34ca2f0..b741cd6 100644 --- a/blight-common/src/main/java/de/blight/common/model/trigger/Trigger.java +++ b/blight-common/src/main/java/de/blight/common/model/trigger/Trigger.java @@ -4,17 +4,38 @@ import de.blight.common.model.MainCharacter; import lombok.Getter; import lombok.Setter; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + @Getter @Setter public abstract class Trigger { - private int requiresChapter; - - public boolean isTriggarable(MainCharacter character) { - return character.getChapter() >= requiresChapter && isTriggarableDelegate(character); - } - - public abstract boolean isTriggarableDelegate(MainCharacter character); + private String triggerId = UUID.randomUUID().toString(); + private int requiresChapter; + private ConditionMode conditionMode = ConditionMode.ALL; + private List conditions = new ArrayList<>(); - public abstract void trigger(MainCharacter character); + public boolean isTriggarable(MainCharacter character) { + if (triggerId != null && character.getFiredTriggerIds().contains(triggerId)) return false; + if (character.getChapter() < requiresChapter) return false; + if (!conditions.isEmpty()) { + if (conditionMode == ConditionMode.ALL) { + if (!conditions.stream().allMatch(c -> c.evaluate(character))) return false; + } else { + if (conditions.stream().noneMatch(c -> c.evaluate(character))) return false; + } + } + return isTriggarableDelegate(character); + } + + public final void fire(MainCharacter character) { + trigger(character); + if (triggerId != null) character.getFiredTriggerIds().add(triggerId); + } + + public abstract boolean isTriggarableDelegate(MainCharacter character); + + public abstract void trigger(MainCharacter character); } diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java b/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java index f3f09f2..e0a008f 100644 --- a/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java +++ b/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java @@ -9,19 +9,6 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; -/** - * Gson-Serialisierung für {@link Trigger}-Instanzen mit Typ-Diskriminator. - * - * JSON-Format (kompakt, kein Pretty-Print für Inline-Verwendung in z. B. LocationZoneIO): - * {@code [{"type":"QUEST_START","requiresChapter":0,"questId":"my_quest"}, ...]} - * - * Bekannte Typen: - *
    - *
  • {@code QUEST_START} → {@link QuestStartTrigger}
  • - *
  • {@code NPC_STATUS} → {@link NpcStatusTrigger}
  • - *
  • {@code FRACTION_STATUS} → {@link FractionStatusTrigger}
  • - *
- */ public final class TriggerIO { public static final String TYPE_QUEST_START = "QUEST_START"; @@ -29,6 +16,7 @@ public final class TriggerIO { public static final String TYPE_FRACTION_STATUS = "FRACTION_STATUS"; public static final String TYPE_CHANGE_ROUTINE = "CHANGE_ROUTINE"; public static final String TYPE_MONOLOGUE = "MONOLOGUE"; + public static final String TYPE_NPC_DIALOG = "NPC_DIALOG"; private static final Gson GSON = new GsonBuilder() .registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter()) @@ -36,18 +24,15 @@ 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 triggers) { if (triggers == null || triggers.isEmpty()) return "[]"; return GSON.toJson(triggers); } - /** Deserialisiert eine Trigger-Liste aus JSON. Gibt leere Liste bei Fehler zurück. */ public static List deserializeList(String json) { if (json == null || json.isBlank() || "[]".equals(json.strip())) return new ArrayList<>(); try { @@ -71,7 +56,13 @@ public final class TriggerIO { public JsonElement serialize(Trigger src, Type typeOfSrc, JsonSerializationContext ctx) { JsonObject obj = new JsonObject(); obj.addProperty("type", typeOf(src)); + if (src.getTriggerId() != null) obj.addProperty("triggerId", src.getTriggerId()); obj.addProperty("requiresChapter", src.getRequiresChapter()); + obj.addProperty("conditionMode", src.getConditionMode().name()); + + JsonArray condArr = new JsonArray(); + for (Condition c : src.getConditions()) condArr.add(serializeCondition(c)); + obj.add("conditions", condArr); if (src instanceof QuestStartTrigger q) { if (q.getQuest() != null && q.getQuest().getQuestId() != null) @@ -86,10 +77,12 @@ public final class TriggerIO { if (f.getTargetStatus() != null) obj.addProperty("targetStatus", f.getTargetStatus().name()); } else if (src instanceof ChangeRoutineTrigger r) { - if (r.getNpcId() != null) obj.addProperty("npcId", r.getNpcId()); + if (r.getNpcId() != null) obj.addProperty("npcId", r.getNpcId()); if (r.getRoutineName() != null) obj.addProperty("routineName", r.getRoutineName()); } else if (src instanceof MonologueTrigger mo) { if (mo.getMonologueId() != null) obj.addProperty("monologueId", mo.getMonologueId()); + } else if (src instanceof NpcDialogTrigger nd) { + if (nd.getNpcId() != null) obj.addProperty("npcId", nd.getNpcId()); } return obj; } @@ -136,20 +129,117 @@ public final class TriggerIO { if (obj.has("monologueId")) mo.setMonologueId(obj.get("monologueId").getAsString()); yield mo; } + case TYPE_NPC_DIALOG -> { + NpcDialogTrigger nd = new NpcDialogTrigger(); + if (obj.has("npcId")) nd.setNpcId(obj.get("npcId").getAsString()); + yield nd; + } default -> null; }; - if (t != null && obj.has("requiresChapter")) - t.setRequiresChapter(obj.get("requiresChapter").getAsInt()); + if (t == null) return null; + if (obj.has("triggerId")) t.setTriggerId(obj.get("triggerId").getAsString()); + if (obj.has("requiresChapter")) t.setRequiresChapter(obj.get("requiresChapter").getAsInt()); + if (obj.has("conditionMode")) { + try { t.setConditionMode(ConditionMode.valueOf(obj.get("conditionMode").getAsString())); } + catch (IllegalArgumentException ignored) {} + } + if (obj.has("conditions")) { + List conds = new ArrayList<>(); + for (JsonElement el : obj.getAsJsonArray("conditions")) { + Condition c = deserializeCondition(el.getAsJsonObject()); + if (c != null) conds.add(c); + } + t.setConditions(conds); + } return t; } + // ── Condition helpers ───────────────────────────────────────────────── + + private static JsonObject serializeCondition(Condition c) { + JsonObject o = new JsonObject(); + if (c instanceof ChapterCondition cc) { + o.addProperty("type", "CHAPTER"); + o.addProperty("minChapter", cc.getMinChapter()); + } else if (c instanceof FirstTimeCondition ft) { + o.addProperty("type", "FIRST_TIME"); + if (ft.getZoneId() != null) o.addProperty("zoneId", ft.getZoneId()); + } else if (c instanceof QuestCompletedCondition qc) { + o.addProperty("type", "QUEST_COMPLETED"); + if (qc.getQuestId() != null) o.addProperty("questId", qc.getQuestId()); + } else if (c instanceof QuestAcceptedCondition qa) { + o.addProperty("type", "QUEST_ACCEPTED"); + if (qa.getQuestId() != null) o.addProperty("questId", qa.getQuestId()); + } else if (c instanceof QuestRejectedCondition qr) { + o.addProperty("type", "QUEST_REJECTED"); + if (qr.getQuestId() != null) o.addProperty("questId", qr.getQuestId()); + } else if (c instanceof GameVariableCondition gv) { + o.addProperty("type", "GAME_VARIABLE"); + if (gv.getKey() != null) o.addProperty("key", gv.getKey()); + if (gv.getValue() != null) o.addProperty("value", gv.getValue()); + } else if (c instanceof FactionMemberCondition fm) { + o.addProperty("type", "FACTION_MEMBER"); + if (fm.getFractionId() != null) o.addProperty("fractionId", fm.getFractionId().toString()); + } + return o; + } + + private static Condition deserializeCondition(JsonObject o) { + String type = o.has("type") ? o.get("type").getAsString() : ""; + return switch (type) { + case "CHAPTER" -> { + ChapterCondition c = new ChapterCondition(); + if (o.has("minChapter")) c.setMinChapter(o.get("minChapter").getAsInt()); + yield c; + } + case "FIRST_TIME" -> { + FirstTimeCondition c = new FirstTimeCondition(); + if (o.has("zoneId")) c.setZoneId(o.get("zoneId").getAsString()); + yield c; + } + case "QUEST_COMPLETED" -> { + QuestCompletedCondition c = new QuestCompletedCondition(); + if (o.has("questId")) c.setQuestId(o.get("questId").getAsString()); + yield c; + } + case "QUEST_ACCEPTED" -> { + QuestAcceptedCondition c = new QuestAcceptedCondition(); + if (o.has("questId")) c.setQuestId(o.get("questId").getAsString()); + yield c; + } + case "QUEST_REJECTED" -> { + QuestRejectedCondition c = new QuestRejectedCondition(); + if (o.has("questId")) c.setQuestId(o.get("questId").getAsString()); + yield c; + } + case "GAME_VARIABLE" -> { + GameVariableCondition c = new GameVariableCondition(); + if (o.has("key")) c.setKey(o.get("key").getAsString()); + if (o.has("value")) c.setValue(o.get("value").getAsString()); + yield c; + } + case "FACTION_MEMBER" -> { + FactionMemberCondition c = new FactionMemberCondition(); + if (o.has("fractionId")) { + try { c.setFractionId(UUID.fromString(o.get("fractionId").getAsString())); } + catch (IllegalArgumentException ignored) {} + } + yield c; + } + default -> null; + }; + } + + // ── Type helpers ────────────────────────────────────────────────────── + private static String typeOf(Trigger t) { if (t instanceof QuestStartTrigger) return TYPE_QUEST_START; if (t instanceof NpcStatusTrigger) return TYPE_NPC_STATUS; if (t instanceof FractionStatusTrigger) return TYPE_FRACTION_STATUS; if (t instanceof ChangeRoutineTrigger) return TYPE_CHANGE_ROUTINE; if (t instanceof MonologueTrigger) return TYPE_MONOLOGUE; + if (t instanceof NpcDialogTrigger) return TYPE_NPC_DIALOG; return "UNKNOWN"; } diff --git a/blight-editor/build.gradle b/blight-editor/build.gradle index 17a9e1f..c1613d4 100644 --- a/blight-editor/build.gradle +++ b/blight-editor/build.gradle @@ -6,7 +6,7 @@ plugins { javafx { version = '26' - modules = ['javafx.controls', 'javafx.swing'] + modules = ['javafx.controls', 'javafx.swing', 'javafx.media'] } application { diff --git a/blight-editor/src/main/java/de/blight/editor/AudioPreviewPopup.java b/blight-editor/src/main/java/de/blight/editor/AudioPreviewPopup.java index abc8555..89be48c 100644 --- a/blight-editor/src/main/java/de/blight/editor/AudioPreviewPopup.java +++ b/blight-editor/src/main/java/de/blight/editor/AudioPreviewPopup.java @@ -180,6 +180,7 @@ public class AudioPreviewPopup { private void buildStage() { stage = new Stage(StageStyle.UTILITY); stage.setTitle("Audio-Vorschau"); + stage.initOwner(de.blight.editor.ui.Dialogs.primaryWindow()); stage.setAlwaysOnTop(true); stage.setResizable(false); stage.setOnCloseRequest(e -> stopClip()); diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index e170017..014bd29 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -240,6 +240,10 @@ public class EditorApp extends Application { // Location-Zonen-Werkzeug-Zustand private VBox locationZoneDynamicContent; + // Zonen-Werkzeug-Zustand (vereint Sound, Area, Location) + private VBox zonenDynamicContent; + private Runnable zonenKindStyleUpdater; + // Spiel-Starten-Werkzeug-Zustand private TextField spawnXField; private TextField spawnZField; @@ -324,9 +328,7 @@ public class EditorApp extends Application { private ToggleButton emitterBtn; private ToggleButton waterBtn; private ToggleButton riverBtn; - private ToggleButton soundAreaBtn; - private ToggleButton areaBtn; - private ToggleButton locationZoneBtn; + private ToggleButton zonenBtn; private ToggleButton playToolBtn; private ToggleButton voxelBtn; private ToggleButton voxelCliffBtn; @@ -350,6 +352,8 @@ public class EditorApp extends Application { private double editPressX, editPressY; private int editPressAction; private javafx.animation.Timeline editTimer; + private final javafx.animation.PauseTransition worldAutoSave = + new javafx.animation.PauseTransition(javafx.util.Duration.seconds(8)); // Asset-Tree-Items (müssen beim Refresh-Signal erreichbar sein) private TreeItem assetTreeRoot; @@ -672,7 +676,7 @@ public class EditorApp extends Application { if (input.areaOverlapRejected) { input.areaOverlapRejected = false; - setStatus("Bereich abgelehnt: überschneidet einen bestehenden Bereich."); + setStatus("Area abgelehnt: überschneidet eine bestehende Area."); } if (input.locationZoneSelectionChanged) { @@ -680,6 +684,12 @@ public class EditorApp extends Application { updateLocationZonePanel(input.selectedLocationZoneInfo); } + if (input.zonenSelectionChanged) { + input.zonenSelectionChanged = false; + updateZonenPanel(input.zonenSelectedKind); + if (zonenKindStyleUpdater != null) zonenKindStyleUpdater.run(); + } + if (input.cliffZoneSelectionChanged) { input.cliffZoneSelectionChanged = false; updateVoxelCliffPanel(input.selectedCliffZoneIdx >= 0); @@ -852,16 +862,12 @@ public class EditorApp extends Application { statusPoller.setCycleCount(javafx.animation.Timeline.INDEFINITE); statusPoller.play(); - javafx.animation.Timeline autoSave = new javafx.animation.Timeline( - new javafx.animation.KeyFrame(javafx.util.Duration.seconds(60), ev -> { - if (!input.saveRequested) { - input.saveRequested = true; - setStatus("Auto-Speicherung…"); - } - }) - ); - autoSave.setCycleCount(javafx.animation.Timeline.INDEFINITE); - autoSave.play(); + worldAutoSave.setOnFinished(ev -> { + if (!input.saveRequested) { + input.saveRequested = true; + setStatus("Auto-Speicherung…"); + } + }); } // ── Modus-Wechsel ──────────────────────────────────────────────────────── @@ -1345,6 +1351,7 @@ public class EditorApp extends Application { MenuItem ctEditItem = new MenuItem("Crafting-Table-Manager"); MenuItem fractionEditItem = new MenuItem("Fraktionen-Manager"); MenuItem locationEditItem = new MenuItem("Locations-Manager"); + MenuItem areaEditItem = new MenuItem("Areas-Manager"); MenuItem localizationEditItem = new MenuItem("Lokalisierungs-Editor"); vegetationsItem.setOnAction(e -> switchToVegetationGenerator()); ezTreeItem.setOnAction(e -> switchToEzTree()); @@ -1359,11 +1366,12 @@ public class EditorApp extends Application { ctEditItem.setOnAction(e -> switchToCraftingTableEditor()); fractionEditItem.setOnAction(e -> switchToFractionEditor()); locationEditItem.setOnAction(e -> switchToLocationEditor()); + areaEditItem.setOnAction(e -> switchToAreaEditor()); localizationEditItem.setOnAction(e -> switchToLocalizationEditor()); toolsMenu.getItems().addAll(vegetationsItem, ezTreeItem, tripoItem, animPrevItem, objEditorItem, worldEditItem, charEditItem, questEditItem, itemEditItem, recipeEditItem, ctEditItem, fractionEditItem, - locationEditItem, localizationEditItem); + locationEditItem, areaEditItem, localizationEditItem); Menu viewMenu = new Menu("Ansicht"); MenuItem resetCam = new MenuItem("Kamera zurücksetzen"); @@ -1425,9 +1433,7 @@ public class EditorApp extends Application { emitterBtn = new ToggleButton("🔥 Emitter"); waterBtn = new ToggleButton("💧 Wasser"); riverBtn = new ToggleButton("↯ Wasserfall"); - soundAreaBtn = new ToggleButton("🔊 Sound"); - areaBtn = new ToggleButton("🗺 Bereiche"); - locationZoneBtn = new ToggleButton("📍 Locations"); + zonenBtn = new ToggleButton("🗺 Zonen"); playToolBtn = new ToggleButton("🎮 Spielen"); voxelBtn = new ToggleButton("⬡ Voxel"); voxelCliffBtn = new ToggleButton("⛰ Klippe"); @@ -1442,9 +1448,7 @@ public class EditorApp extends Application { emitterBtn.setStyle("-fx-font-weight:bold;"); waterBtn.setStyle("-fx-font-weight:bold;"); riverBtn.setStyle("-fx-font-weight:bold;"); - soundAreaBtn.setStyle("-fx-font-weight:bold;"); - areaBtn.setStyle("-fx-font-weight:bold;"); - locationZoneBtn.setStyle("-fx-font-weight:bold;"); + zonenBtn.setStyle("-fx-font-weight:bold;"); playToolBtn.setStyle("-fx-font-weight:bold;"); voxelBtn.setStyle("-fx-font-weight:bold;"); voxelCliffBtn.setStyle("-fx-font-weight:bold;"); @@ -1461,9 +1465,7 @@ public class EditorApp extends Application { emitterBtn.setToggleGroup(layerGroup); waterBtn.setToggleGroup(layerGroup); riverBtn.setToggleGroup(layerGroup); - soundAreaBtn.setToggleGroup(layerGroup); - areaBtn.setToggleGroup(layerGroup); - locationZoneBtn.setToggleGroup(layerGroup); + zonenBtn.setToggleGroup(layerGroup); playToolBtn.setToggleGroup(layerGroup); voxelBtn.setToggleGroup(layerGroup); voxelCliffBtn.setToggleGroup(layerGroup); @@ -1523,17 +1525,9 @@ public class EditorApp extends Application { input.activeLayer = SharedInput.LAYER_WATERFALL; root.setRight(buildWaterfallPanel()); }); - soundAreaBtn.setOnAction(e -> { - input.activeLayer = SharedInput.LAYER_SOUND_AREAS; - root.setRight(buildSoundAreaPanel()); - }); - areaBtn.setOnAction(e -> { - input.activeLayer = SharedInput.LAYER_AREAS; - root.setRight(buildAreaPanel()); - }); - locationZoneBtn.setOnAction(e -> { - input.activeLayer = SharedInput.LAYER_LOCATION_ZONES; - root.setRight(buildLocationZonePanel()); + zonenBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_ZONEN; + root.setRight(buildZonenPanel()); }); playToolBtn.setOnAction(e -> { input.activeLayer = SharedInput.LAYER_PLAY_TOOL; @@ -1574,7 +1568,7 @@ public class EditorApp extends Application { new Separator(Orientation.VERTICAL), emitterBtn, new Separator(Orientation.VERTICAL), waterBtn, new Separator(Orientation.VERTICAL), riverBtn, - new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn, + new Separator(Orientation.VERTICAL), zonenBtn, new Separator(Orientation.VERTICAL), playToolBtn, new Separator(Orientation.VERTICAL), voxelBtn, voxelCliffBtn, new Separator(Orientation.VERTICAL), stoneBtn, @@ -4643,9 +4637,9 @@ public class EditorApp extends Application { case "light" -> { input.activeLayer = SharedInput.LAYER_LIGHTS; root.setRight(buildLightPanel()); } case "emitter" -> { input.activeLayer = SharedInput.LAYER_EMITTERS; root.setRight(buildEmitterPanel()); } case "water" -> { input.activeLayer = SharedInput.LAYER_WATER; root.setRight(buildWaterPanel()); } - case "soundarea" -> { input.activeLayer = SharedInput.LAYER_SOUND_AREAS; root.setRight(buildSoundAreaPanel()); } - case "area" -> { input.activeLayer = SharedInput.LAYER_AREAS; root.setRight(buildAreaPanel()); } - case "locationzone" -> { input.activeLayer = SharedInput.LAYER_LOCATION_ZONES; root.setRight(buildLocationZonePanel()); } + case "soundarea" -> { input.activeLayer = SharedInput.LAYER_ZONEN; input.zonenNewKind = "sound"; root.setRight(buildZonenPanel()); zonenBtn.setSelected(true); } + case "area" -> { input.activeLayer = SharedInput.LAYER_ZONEN; input.zonenNewKind = "area"; root.setRight(buildZonenPanel()); zonenBtn.setSelected(true); } + case "locationzone" -> { input.activeLayer = SharedInput.LAYER_ZONEN; input.zonenNewKind = "location"; root.setRight(buildZonenPanel()); zonenBtn.setSelected(true); } case "waterfall" -> { input.activeLayer = SharedInput.LAYER_WATERFALL; root.setRight(buildWaterfallPanel()); } } }, @@ -4707,6 +4701,7 @@ public class EditorApp extends Application { input.reloadPlacedOther = true; } } + scheduleAutoSave(); } ); @@ -7857,19 +7852,30 @@ public class EditorApp extends Application { } else if (bothDown) { stopEditTimer(); } else if (e.getButton() == MouseButton.PRIMARY && !e.isAltDown()) { + boolean singleClickLayer = input.activeLayer == SharedInput.LAYER_PLAY_TOOL + || input.activeLayer == SharedInput.LAYER_ZONEN + || input.activeLayer == SharedInput.LAYER_SOUND_AREAS + || input.activeLayer == SharedInput.LAYER_AREAS + || input.activeLayer == SharedInput.LAYER_LOCATION_ZONES; if (input.activeLayer == SharedInput.LAYER_PLAY_TOOL) { // Einzel-Klick ohne Edit-Timer (verhindert Dauer-Spam) input.playToolClickQueue.offer( new SharedInput.PlayToolClick((float) e.getX(), (float) e.getY())); + } else if (singleClickLayer) { + submitEdit(e.getX(), e.getY(), +1); } else { editPressX = e.getX(); editPressY = e.getY(); editPressAction = +1; submitEdit(editPressX, editPressY, editPressAction); startEditTimer(); } } else if (e.getButton() == MouseButton.SECONDARY) { + boolean singleClickLayer = input.activeLayer == SharedInput.LAYER_ZONEN + || input.activeLayer == SharedInput.LAYER_SOUND_AREAS + || input.activeLayer == SharedInput.LAYER_AREAS + || input.activeLayer == SharedInput.LAYER_LOCATION_ZONES; editPressX = e.getX(); editPressY = e.getY(); editPressAction = -1; submitEdit(editPressX, editPressY, editPressAction); - startEditTimer(); + if (!singleClickLayer) startEditTimer(); } }); @@ -7969,6 +7975,8 @@ public class EditorApp extends Application { if (editTimer != null) { editTimer.stop(); editTimer = null; } } + private void scheduleAutoSave() { worldAutoSave.playFromStart(); } + private void submitEdit(double x, double y, int action) { switch (input.activeLayer) { case 0 -> input.editQueue.offer(new SharedInput.TerrainEdit((float) x, (float) y, action)); @@ -7992,6 +8000,11 @@ public class EditorApp extends Application { input.areaClickQueue.offer(new SharedInput.AreaClick((float) x, (float) y, action < 0)); case SharedInput.LAYER_LOCATION_ZONES -> input.locationZoneClickQueue.offer(new SharedInput.LocationZoneClick((float) x, (float) y, action < 0)); + case SharedInput.LAYER_ZONEN -> { + input.soundAreaClickQueue.offer(new SharedInput.SoundAreaClick((float) x, (float) y, action < 0)); + input.areaClickQueue.offer(new SharedInput.AreaClick((float) x, (float) y, action < 0)); + input.locationZoneClickQueue.offer(new SharedInput.LocationZoneClick((float) x, (float) y, action < 0)); + } case SharedInput.LAYER_VOXEL_CLIFF -> input.voxelCliffClickQueue.offer(new SharedInput.VoxelCliffClick((float) x, (float) y, action < 0)); case SharedInput.LAYER_PLAY_TOOL -> { @@ -8008,6 +8021,7 @@ public class EditorApp extends Application { new SharedInput.ModelInteractableClick((float) x, (float) y)); } } + scheduleAutoSave(); } // ── Statusleiste ───────────────────────────────────────────────────────── @@ -8328,6 +8342,7 @@ public class EditorApp extends Application { if (pressed && (input.activeLayer == SharedInput.LAYER_SOUND_AREAS || input.activeLayer == SharedInput.LAYER_AREAS || input.activeLayer == SharedInput.LAYER_LOCATION_ZONES + || input.activeLayer == SharedInput.LAYER_ZONEN || input.activeLayer == SharedInput.LAYER_WATER || input.activeLayer == SharedInput.LAYER_VOXEL_CLIFF)) input.cancelZoneDrawing = true; @@ -8337,12 +8352,17 @@ public class EditorApp extends Application { if (input.activeLayer == SharedInput.LAYER_OBJECTS || input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT) input.deleteSelectedRequested = true; - else if (input.activeLayer == SharedInput.LAYER_SOUND_AREAS) - input.deleteSoundAreaRequested = true; - else if (input.activeLayer == SharedInput.LAYER_AREAS) - input.deleteAreaRequested = true; - else if (input.activeLayer == SharedInput.LAYER_LOCATION_ZONES) - input.deleteLocationZoneRequested = true; + else if (input.activeLayer == SharedInput.LAYER_SOUND_AREAS) { + input.deleteSoundAreaRequested = true; scheduleAutoSave(); + } else if (input.activeLayer == SharedInput.LAYER_AREAS) { + input.deleteAreaRequested = true; scheduleAutoSave(); + } else if (input.activeLayer == SharedInput.LAYER_LOCATION_ZONES) { + input.deleteLocationZoneRequested = true; scheduleAutoSave(); + } else if (input.activeLayer == SharedInput.LAYER_ZONEN) { + if ("sound".equals(input.zonenSelectedKind)) { input.deleteSoundAreaRequested = true; scheduleAutoSave(); } + else if ("area".equals(input.zonenSelectedKind)) { input.deleteAreaRequested = true; scheduleAutoSave(); } + else if ("location".equals(input.zonenSelectedKind)) { input.deleteLocationZoneRequested = true; scheduleAutoSave(); } + } else if (input.activeLayer == SharedInput.LAYER_VOXEL_CLIFF) input.deleteVoxelCliffZoneRequested = true; else if (input.activeLayer == SharedInput.LAYER_WATER) @@ -8509,6 +8529,115 @@ public class EditorApp extends Application { if (voxelCliffGenerateBtn != null) voxelCliffGenerateBtn.setDisable(false); } + // ── Zonen-Panel (vereint Sound, Area, Location) ─────────────────────────── + + private VBox buildZonenPanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + + final String STYLE_AREA_ON = "-fx-background-color: #e05050; -fx-text-fill: white; -fx-font-weight: bold; -fx-border-color: white; -fx-border-width: 2;"; + final String STYLE_AREA_OFF = "-fx-background-color: #b03030; -fx-text-fill: #ddd; -fx-font-weight: bold;"; + final String STYLE_SOUND_ON = "-fx-background-color: #e07818; -fx-text-fill: white; -fx-font-weight: bold; -fx-border-color: white; -fx-border-width: 2;"; + final String STYLE_SOUND_OFF = "-fx-background-color: #a05010; -fx-text-fill: #ddd; -fx-font-weight: bold;"; + final String STYLE_LOC_ON = "-fx-background-color: #c8a800; -fx-text-fill: white; -fx-font-weight: bold; -fx-border-color: white; -fx-border-width: 2;"; + final String STYLE_LOC_OFF = "-fx-background-color: #8a7200; -fx-text-fill: #ddd; -fx-font-weight: bold;"; + + Button areaKindBtn = new Button("+ Neue Area zeichnen"); + Button soundKindBtn = new Button("+ Neuen Sound zeichnen"); + Button locationKindBtn = new Button("+ Neue Location zeichnen"); + areaKindBtn.setMaxWidth(Double.MAX_VALUE); + soundKindBtn.setMaxWidth(Double.MAX_VALUE); + locationKindBtn.setMaxWidth(Double.MAX_VALUE); + + Runnable updateKindStyles = () -> { + String k = input.zonenNewKind; // null = kein Zeichenmodus aktiv + areaKindBtn.setStyle("area".equals(k) ? STYLE_AREA_ON : STYLE_AREA_OFF); + soundKindBtn.setStyle("sound".equals(k) ? STYLE_SOUND_ON : STYLE_SOUND_OFF); + locationKindBtn.setStyle("location".equals(k) ? STYLE_LOC_ON : STYLE_LOC_OFF); + }; + updateKindStyles.run(); + zonenKindStyleUpdater = updateKindStyles; + + areaKindBtn.setOnAction(e -> { + input.zonenNewKind = "area"; + updateKindStyles.run(); + }); + soundKindBtn.setOnAction(e -> { + input.zonenNewKind = "sound"; + updateKindStyles.run(); + }); + locationKindBtn.setOnAction(e -> { + input.zonenNewKind = "location"; + updateKindStyles.run(); + }); + + areaKindBtn.setTooltip(new javafx.scene.control.Tooltip( + "Area: Musik-Zone mit Tag-, Nacht- und Kampf-Track.\nAreas dürfen sich nicht überschneiden.")); + soundKindBtn.setTooltip(new javafx.scene.control.Tooltip( + "Sound: Ambient-Soundbereich mit wählbarer Audio-Datei,\nLautstärke und Loop-Crossfade.")); + locationKindBtn.setTooltip(new javafx.scene.control.Tooltip( + "Location: Gameplay-Trigger-Zone, z.B. zum Starten\nvon Quests oder Dialogen beim Betreten.")); + + inner.getChildren().addAll( + sectionTitle("Neue Zone"), + areaKindBtn, soundKindBtn, locationKindBtn, + new Separator(), + styledHint("L-Klick → Polygon-Punkte setzen"), + styledHint("R-Klick (beim Zeichnen) → letzten Punkt rückgängig"), + styledHint("R-Klick (sonst) → Auswahl aufheben"), + styledHint("ESC → Zeichnen abbrechen"), + styledHint("Entf → Gewählte Zone löschen"), + new Separator(), + sectionTitle("Gewählte Zone"), + new Separator()); + + zonenDynamicContent = new VBox(6); + Label noSel = new Label("Keine Zone ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + zonenDynamicContent.getChildren().add(noSel); + inner.getChildren().add(zonenDynamicContent); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(270); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void updateZonenPanel(String kind) { + if (zonenDynamicContent == null) return; + zonenDynamicContent.getChildren().clear(); + switch (kind != null ? kind : "") { + case "area" -> { + VBox saved = areaDynamicContent; + areaDynamicContent = zonenDynamicContent; + updateAreaPanel(input.selectedAreaInfo); + areaDynamicContent = saved; + } + case "sound" -> { + VBox saved = soundAreaDynamicContent; + soundAreaDynamicContent = zonenDynamicContent; + updateSoundAreaPanel(input.selectedSoundAreaInfo); + soundAreaDynamicContent = saved; + } + case "location" -> { + VBox saved = locationZoneDynamicContent; + locationZoneDynamicContent = zonenDynamicContent; + updateLocationZonePanel(input.selectedLocationZoneInfo); + locationZoneDynamicContent = saved; + } + default -> { + Label noSel = new Label("Keine Zone ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + zonenDynamicContent.getChildren().add(noSel); + } + } + } + private VBox buildSoundAreaPanel() { VBox inner = new VBox(8); inner.setPadding(new Insets(10)); @@ -8575,27 +8704,17 @@ public class EditorApp extends Application { soundLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); soundLabel.setWrapText(true); - Button browseBtn = new Button("📂 Datei wählen…"); + Button browseBtn = new Button("🔊 Sound wählen…"); browseBtn.setMaxWidth(Double.MAX_VALUE); browseBtn.setOnAction(e -> { - FileChooser fc = new FileChooser(); - fc.setTitle("Sound-Datei wählen"); - fc.getExtensionFilters().add( - new FileChooser.ExtensionFilter("Audio (OGG, WAV, MP3)", "*.ogg", "*.wav", "*.mp3")); Path assetRoot = ProjectRoot.resolve("blight-assets", "src", "main", "resources"); - if (java.nio.file.Files.isDirectory(assetRoot)) - fc.setInitialDirectory(assetRoot.toFile()); - File chosen = fc.showOpenDialog(primaryStage); - if (chosen != null) { - try { - String rel = ensureOgg(chosen, assetRoot); - soundPath[0] = rel; - soundLabel.setText("Sound: " + rel); - sendSoundAreaUpdate(idx, soundPath[0], vol[0], cf[0]); - } catch (Exception ex) { - setStatus("Fehler bei OGG-Konvertierung: " + ex.getMessage()); - } - } + new de.blight.editor.ui.SoundChooser(assetRoot, de.blight.editor.ui.SoundChooser.Mode.ALL) + .showAndWait() + .ifPresent(rel -> { + soundPath[0] = rel; + soundLabel.setText("Sound: " + rel); + sendSoundAreaUpdate(idx, soundPath[0], vol[0], cf[0]); + }); }); Label volLbl = new Label("Lautstärke"); @@ -8639,6 +8758,7 @@ public class EditorApp extends Application { // pendingSoundArea != null and idx matches to update only the props. input.pendingSoundArea.set(new de.blight.common.PlacedSoundArea( new float[0], new float[0], soundPath, volume, crossfade)); + scheduleAutoSave(); } // ── Musik-Bereich-Panel ─────────────────────────────────────────────────── @@ -8647,18 +8767,18 @@ public class EditorApp extends Application { VBox inner = new VBox(8); inner.setPadding(new Insets(10)); inner.getChildren().addAll( - sectionTitle("Bereiche"), + sectionTitle("Areas"), styledHint("L-Klick → Polygon-Punkte setzen"), styledHint("R-Klick → Polygon schließen / Auswahl aufheben"), styledHint("ESC → Zeichnen abbrechen"), - styledHint("Entf → Bereich löschen"), - styledHint("Bereiche dürfen sich nicht überschneiden"), + styledHint("Entf → Area löschen"), + styledHint("Areas dürfen sich nicht überschneiden"), new Separator(), - sectionTitle("Gewählter Bereich"), + sectionTitle("Gewählte Area"), new Separator()); areaDynamicContent = new VBox(6); - Label noSel = new Label("Kein Bereich ausgewählt"); + Label noSel = new Label("Keine Area ausgewählt"); noSel.setStyle("-fx-text-fill: #888;"); areaDynamicContent.getChildren().add(noSel); inner.getChildren().add(areaDynamicContent); @@ -8679,71 +8799,80 @@ public class EditorApp extends Application { areaDynamicContent.getChildren().clear(); if (info == null) { - Label noSel = new Label("Kein Bereich ausgewählt"); + Label noSel = new Label("Keine Area ausgewählt"); noSel.setStyle("-fx-text-fill: #888;"); areaDynamicContent.getChildren().add(noSel); return; } - // Format: "idx|nameId|dayTrack|nightTrack|combatTrack" - String[] p = info.split("\\|", -1); - if (p.length < 5) return; + // Format: "idx|areaId|triggersJson" + String[] p = info.split("\\|", 3); + if (p.length < 1) return; try { int idx = Integer.parseInt(p[0]); - final String[] nameRef = {p[1]}; - final String[] tracks = {p[2], p[3], p[4]}; - String[] trackLabels = {"☀ Tag-Track", "🌙 Nacht-Track", "⚔ Kampf-Track"}; + String currentAreaId = p.length > 1 ? p[1] : ""; + java.util.List existingTriggers = + p.length > 2 + ? de.blight.common.model.trigger.TriggerIO.deserializeList(p[2]) + : new java.util.ArrayList<>(); - Runnable publish = () -> - input.pendingArea.set(new de.blight.common.PlacedArea( - new float[0], new float[0], nameRef[0], tracks[0], tracks[1], tracks[2])); + // Verfügbare Area-Definitionen laden + java.util.List areaIds = new java.util.ArrayList<>(); + areaIds.add(""); + try { + de.blight.common.AreaDefinitionIO.load().stream() + .map(de.blight.common.AreaDefinition::id) + .filter(s -> !s.isBlank()) + .forEach(areaIds::add); + } catch (Exception ignored) {} - // Name (TextReference-ID) - Label nameLbl = new Label("Name (TextReference):"); - nameLbl.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); - TextField nameTf = new TextField(nameRef[0]); - nameTf.setPromptText("z.B. area.village"); - nameTf.textProperty().addListener((obs, o, n) -> { nameRef[0] = n; publish.run(); }); - areaDynamicContent.getChildren().addAll(nameLbl, nameTf, new Separator()); + Label areaLbl = new Label("Verknüpfte Area:"); + areaLbl.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); - for (int ti = 0; ti < 3; ti++) { - final int tIdx = ti; - Label lbl = new Label(trackLabels[ti] + ": " - + (tracks[ti].isEmpty() ? "(keine)" : tracks[ti])); - lbl.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); - lbl.setWrapText(true); + Label nameKeyLbl = new Label(currentAreaId.isBlank() ? "" : currentAreaId + ".name"); + nameKeyLbl.setStyle("-fx-font-size: 10; -fx-text-fill: #888; -fx-font-style: italic;"); - Button btn = new Button("📂 Wählen…"); - btn.setMaxWidth(Double.MAX_VALUE); - btn.setOnAction(e -> { - FileChooser fc = new FileChooser(); - fc.setTitle(trackLabels[tIdx] + " wählen"); - fc.getExtensionFilters().add( - new FileChooser.ExtensionFilter("Audio (OGG, WAV, MP3)", "*.ogg", "*.wav", "*.mp3")); - Path assetRoot = ProjectRoot.resolve("blight-assets", "src", "main", "resources"); - if (java.nio.file.Files.isDirectory(assetRoot)) - fc.setInitialDirectory(assetRoot.toFile()); - File chosen = fc.showOpenDialog(primaryStage); - if (chosen != null) { - try { - Path assetRootPath = ProjectRoot.resolve("blight-assets", "src", "main", "resources"); - tracks[tIdx] = ensureOgg(chosen, assetRootPath); - lbl.setText(trackLabels[tIdx] + ": " + tracks[tIdx]); - publish.run(); - } catch (Exception ex) { - setStatus("Fehler bei OGG-Konvertierung: " + ex.getMessage()); - } - } - }); + // editorHolder muss vor areaCombo deklariert sein (Lambda-Forward-Ref) + de.blight.editor.ui.TriggerListEditor[] editorHolder = {null}; - areaDynamicContent.getChildren().addAll(lbl, btn); - if (ti < 2) areaDynamicContent.getChildren().add(new Separator()); - } + ComboBox areaCombo = new ComboBox<>(); + areaCombo.getItems().setAll(areaIds); + areaCombo.setValue(areaIds.contains(currentAreaId) ? currentAreaId : ""); + areaCombo.setMaxWidth(Double.MAX_VALUE); + areaCombo.setPromptText("Area wählen…"); + areaCombo.valueProperty().addListener((obs, o, n) -> { + String aid = n != null ? n : ""; + nameKeyLbl.setText(aid.isBlank() ? "" : aid + ".name"); + java.util.List curTriggers = + editorHolder[0] != null + ? new java.util.ArrayList<>(editorHolder[0].getTriggers()) + : existingTriggers; + input.pendingArea.set(new de.blight.common.PlacedArea( + new float[0], new float[0], aid, curTriggers)); + scheduleAutoSave(); + }); + + Label triggerLbl = new Label("Trigger:"); + triggerLbl.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); + + editorHolder[0] = new de.blight.editor.ui.TriggerListEditor(existingTriggers, () -> { + String aid = areaCombo.getValue() != null ? areaCombo.getValue() : currentAreaId; + input.pendingArea.set(new de.blight.common.PlacedArea( + new float[0], new float[0], aid, + new java.util.ArrayList<>(editorHolder[0].getTriggers()))); + scheduleAutoSave(); + }); Button delBtn = new Button("🗑 Löschen"); delBtn.setMaxWidth(Double.MAX_VALUE); delBtn.setStyle("-fx-text-fill: #c0392b;"); - delBtn.setOnAction(e -> input.deleteAreaRequested = true); - areaDynamicContent.getChildren().addAll(new Separator(), delBtn); + delBtn.setOnAction(e -> { input.deleteAreaRequested = true; scheduleAutoSave(); }); + + areaDynamicContent.getChildren().addAll( + areaLbl, areaCombo, nameKeyLbl, + new Separator(), + triggerLbl, editorHolder[0], + new Separator(), + delBtn); } catch (NumberFormatException ignored) {} } @@ -8795,14 +8924,11 @@ public class EditorApp extends Application { try { int idx = Integer.parseInt(p[0]); String nameId = p.length > 1 ? p[1] : ""; - java.util.List initTriggers = + java.util.List existingTriggers = p.length > 2 ? de.blight.common.model.trigger.TriggerIO.deserializeList(p[2]) : new java.util.ArrayList<>(); - // Holder damit triggerEditor und nameTf sich gegenseitig referenzieren können - final de.blight.editor.ui.TriggerListEditor[] teHolder = {null}; - // Verfügbare Locations aus LocationIO laden java.util.List locationIds = new java.util.ArrayList<>(); locationIds.add(""); @@ -8815,44 +8941,30 @@ public class EditorApp extends Application { Label nameLbl = new Label("Verknüpfte Location:"); nameLbl.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); + + Label nameKeyLbl = new Label(nameId.isBlank() ? "" : nameId + ".name"); + nameKeyLbl.setStyle("-fx-font-size: 10; -fx-text-fill: #888; -fx-font-style: italic;"); + ComboBox locationCombo = new ComboBox<>(); locationCombo.getItems().setAll(locationIds); locationCombo.setValue(locationIds.contains(nameId) ? nameId : ""); locationCombo.setMaxWidth(Double.MAX_VALUE); locationCombo.setPromptText("Location wählen…"); - // Fallback: Freitext falls Location nicht in Liste - TextField nameTf = new TextField(nameId); - nameTf.setPromptText("oder manuell: location.village"); locationCombo.valueProperty().addListener((obs, o, n) -> { - if (n != null && !n.isBlank()) nameTf.setText(n); + String lid = n != null ? n : ""; + nameKeyLbl.setText(lid.isBlank() ? "" : lid + ".name"); input.pendingLocationZone.set(new de.blight.common.PlacedLocationZone( - new float[0], new float[0], - n != null && !n.isBlank() ? n : nameTf.getText(), - teHolder[0] != null ? teHolder[0].getTriggers() : initTriggers)); + new float[0], new float[0], lid, existingTriggers)); + scheduleAutoSave(); }); - nameTf.textProperty().addListener((obs, o, n) -> - input.pendingLocationZone.set(new de.blight.common.PlacedLocationZone( - new float[0], new float[0], n, - teHolder[0] != null ? teHolder[0].getTriggers() : initTriggers))); - - Label triggerLbl = new Label("Trigger:"); - triggerLbl.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); - de.blight.editor.ui.TriggerListEditor triggerEditor = - new de.blight.editor.ui.TriggerListEditor(initTriggers, () -> - input.pendingLocationZone.set(new de.blight.common.PlacedLocationZone( - new float[0], new float[0], nameTf.getText(), - teHolder[0] != null ? teHolder[0].getTriggers() : initTriggers))); - teHolder[0] = triggerEditor; Button delBtn = new Button("🗑 Löschen"); delBtn.setMaxWidth(Double.MAX_VALUE); delBtn.setStyle("-fx-text-fill: #c0392b;"); - delBtn.setOnAction(e -> input.deleteLocationZoneRequested = true); + delBtn.setOnAction(e -> { input.deleteLocationZoneRequested = true; scheduleAutoSave(); }); locationZoneDynamicContent.getChildren().addAll( - nameLbl, locationCombo, nameTf, - new Separator(), - triggerLbl, triggerEditor, + nameLbl, locationCombo, nameKeyLbl, new Separator(), delBtn); @@ -11239,6 +11351,20 @@ public class EditorApp extends Application { root.setRight(null); } + private void switchToAreaEditor() { + onF5 = null; + currentTool = "areaEditor"; + ToolBar tb = new ToolBar(); + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Areas-Manager"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + topBar.getChildren().set(1, tb); + root.setCenter(new de.blight.editor.ui.AreaEditorView()); + root.setRight(null); + } + private void switchToLocalizationEditor() { onF5 = null; currentTool = "localizationEditor"; diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index 64dd4e5..ec597bb 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -356,9 +356,11 @@ public class SharedInput { public volatile float pendingGotoPitch = Float.NaN; // ── Reload-Signale nach Löschung aus MapObjectsView ────────────────────── - public volatile boolean reloadPlacedModels = false; - public volatile boolean reloadPlacedItems = false; - public volatile boolean reloadPlacedOther = false; // Lichter, Emitter, Wasser, Bereiche, Zonen + public volatile boolean reloadPlacedModels = false; + public volatile boolean reloadPlacedItems = false; + public volatile boolean reloadPlacedOther = false; // Lichter, Emitter, Wasser, Bereiche, Zonen + /** Nur Lichter + Emitter neu laden (kein Zonen-Reload), gesetzt von SceneObjectState. */ + public volatile boolean reloadLightsEmitters = false; /** Yaw in Grad: 0° = Süden (−Z), 90° = Westen (−X), ±180° = Norden (+Z). */ public volatile float camYaw = 0f; /** Pitch in Grad: positiv = Blick nach oben, negativ = nach unten. */ @@ -546,6 +548,18 @@ public class SharedInput { /** JavaFX → JME: Laufendes Polygon-Zeichnen abbrechen (ESC). */ public volatile boolean cancelZoneDrawing = false; + // ── Zonen-Werkzeug (vereint Sound, Area, Location) ──────────────────────── + /** activeLayer==32 → alle Zonen-Typen gleichzeitig sichtbar und editierbar */ + public static final int LAYER_ZONEN = 32; + + /** JavaFX → JME: welcher Zonen-Typ neu gezeichnet wird ("area"|"sound"|"location"). */ + public volatile String zonenNewKind = "area"; + + /** JME → JavaFX: Typ der aktuell selektierten Zone ("area"|"sound"|"location"|null). */ + public volatile String zonenSelectedKind = null; + /** JME → JavaFX: Selektion hat sich geändert → Zonen-Panel aktualisieren. */ + public volatile boolean zonenSelectionChanged = false; + // ── Spiel-Starten-Werkzeug ──────────────────────────────────────────────── /** Klick/Drag-Ereignisse im Viewport für das Play-Tool. */ public record PlayToolClick(float screenX, float screenY) {} diff --git a/blight-editor/src/main/java/de/blight/editor/state/AreaState.java b/blight-editor/src/main/java/de/blight/editor/state/AreaState.java index adf3cf9..8556f8b 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/AreaState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/AreaState.java @@ -13,6 +13,7 @@ import com.jme3.scene.VertexBuffer; import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.util.BufferUtils; import de.blight.common.PlacedArea; +import de.blight.common.model.trigger.TriggerIO; import de.blight.editor.SharedInput; import java.nio.FloatBuffer; @@ -23,10 +24,10 @@ public class AreaState extends BaseAppState { private static final float LINE_OFFSET_Y = 0.35f; - private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.5f, 0.3f, 1f, 1f); - private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 0.9f, 0.2f, 1f); - private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.8f, 0.5f, 1f, 1f); - private static final ColorRGBA COLOR_OVERLAP = new ColorRGBA(1f, 0.2f, 0.2f, 1f); + private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.90f, 0.15f, 0.15f, 1f); // Rot + private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1.00f, 0.55f, 0.55f, 1f); // Hell-Rot + private static final ColorRGBA COLOR_INPROG = new ColorRGBA(1.00f, 0.40f, 0.40f, 1f); // Mittel-Rot + private static final ColorRGBA COLOR_OVERLAP = new ColorRGBA(1.00f, 0.85f, 0.00f, 1f); // Gelb (Fehler) private final SharedInput input; private SimpleApplication app; @@ -79,7 +80,7 @@ public class AreaState extends BaseAppState { @Override public void update(float tpf) { - if (input.activeLayer != SharedInput.LAYER_AREAS) { + if (input.activeLayer != SharedInput.LAYER_AREAS && input.activeLayer != SharedInput.LAYER_ZONEN) { if (placing) cancelPoly(); return; } @@ -94,9 +95,9 @@ public class AreaState extends BaseAppState { applyProperty(selectedIdx, pending); } - if (input.cancelZoneDrawing) { + if (input.cancelZoneDrawing && placing) { input.cancelZoneDrawing = false; - if (placing) cancelPoly(); + cancelPoly(); } if (input.deleteAreaRequested) { @@ -116,16 +117,13 @@ public class AreaState extends BaseAppState { Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); if (click.rightButton()) { - if (placing) closePoly(); + if (placing) undoLastPoint(); else deselect(); return; } - if (terrain == null) return; - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() == 0) return; - Vector3f pt = hits.getClosestCollision().getContactPoint(); + Vector3f pt = raycastAll(ray); + if (pt == null) return; float hitX = pt.x, hitZ = pt.z; if (placing) { @@ -133,10 +131,11 @@ public class AreaState extends BaseAppState { hitX = snapped[0]; hitZ = snapped[1]; + // auto-close only when snapped exactly to own first vertex if (currX.size() >= 3) { float dx = hitX - currX.get(0); float dz = hitZ - currZ.get(0); - if (dx * dx + dz * dz < SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST * 0.25f) { + if (dx * dx + dz * dz < 0.01f) { closePoly(); return; } @@ -146,19 +145,41 @@ public class AreaState extends BaseAppState { currZ.add(hitZ); updateInProgressGeo(); } else { + // don't select while another state is drawing + SoundAreaState sas0 = getStateManager().getState(SoundAreaState.class); + LocationZoneState lzs0 = getStateManager().getState(LocationZoneState.class); + if ((sas0 != null && sas0.isPlacing()) || (lzs0 != null && lzs0.isPlacing())) return; + + // pass 1: clearly inside for (int i = 0; i < areas.size(); i++) { PlacedArea a = areas.get(i); if (SoundAreaState.pointInPolygon(hitX, hitZ, a.pointsX(), a.pointsZ())) { - selectArea(i); - return; + selectArea(i); return; } } + // pass 2: within 50 cm of any edge + for (int i = 0; i < areas.size(); i++) { + PlacedArea a = areas.get(i); + if (SoundAreaState.pointNearPolygonEdge(hitX, hitZ, a.pointsX(), a.pointsZ(), 0.5f)) { + selectArea(i); return; + } + } + // cross-type: if another zone type is here, don't start drawing + if (input.activeLayer == SharedInput.LAYER_ZONEN) { + SoundAreaState sas = getStateManager().getState(SoundAreaState.class); + if (sas != null && sas.findZoneAt(hitX, hitZ) >= 0) return; + LocationZoneState lzs = getStateManager().getState(LocationZoneState.class); + if (lzs != null && lzs.findZoneAt(hitX, hitZ) >= 0) return; + } deselect(); + if (input.activeLayer == SharedInput.LAYER_ZONEN && !"area".equals(input.zonenNewKind)) return; placing = true; currX.clear(); currZ.clear(); - currX.add(hitX); - currZ.add(hitZ); + // snap first point to nearby existing vertices + float[] startSnap = snapVertex(hitX, hitZ); + currX.add(startSnap[0]); + currZ.add(startSnap[1]); updateInProgressGeo(); } } @@ -195,9 +216,10 @@ public class AreaState extends BaseAppState { return; } - PlacedArea area = new PlacedArea(xs, zs, "", "", "", ""); + PlacedArea area = new PlacedArea(xs, zs, ""); addArea(area); selectArea(areas.size() - 1); + if (input.activeLayer == SharedInput.LAYER_ZONEN) input.zonenNewKind = null; cancelPoly(); } @@ -209,6 +231,14 @@ public class AreaState extends BaseAppState { if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; } } + private void undoLastPoint() { + if (currX.isEmpty()) { cancelPoly(); return; } + currX.remove(currX.size() - 1); + currZ.remove(currZ.size() - 1); + if (currX.isEmpty()) cancelPoly(); + else updateInProgressGeo(); + } + private void updateInProgressGeo() { if (inProgGeo != null) rootNode.detachChild(inProgGeo); int n = currX.size(); @@ -224,7 +254,7 @@ public class AreaState extends BaseAppState { float x = currX.get(currX.size() - 1); float z = currZ.get(currZ.size() - 1); - float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f; + float y = getHeightAt(x, z) + LINE_OFFSET_Y + 0.05f; float s = 1.5f; FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3); @@ -251,25 +281,42 @@ public class AreaState extends BaseAppState { private void selectArea(int idx) { if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) { areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + areaGeos.get(selectedIdx).getMaterial().getAdditionalRenderState().setLineWidth(4f); } + // cross-state deselect + SoundAreaState sas = getStateManager().getState(SoundAreaState.class); + if (sas != null) sas.deselectSilent(); + LocationZoneState lzs = getStateManager().getState(LocationZoneState.class); + if (lzs != null) lzs.deselectSilent(); selectedIdx = idx; areaGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED); + areaGeos.get(idx).getMaterial().getAdditionalRenderState().setLineWidth(8f); publishSelection(idx); } private void deselect() { if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) { areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + areaGeos.get(selectedIdx).getMaterial().getAdditionalRenderState().setLineWidth(4f); } selectedIdx = -1; input.selectedAreaInfo = null; input.areaSelectionChanged = true; + if ("area".equals(input.zonenSelectedKind)) { + input.zonenSelectedKind = null; + input.zonenSelectionChanged = true; + } } private void publishSelection(int idx) { PlacedArea a = areas.get(idx); - input.selectedAreaInfo = idx + "|" + a.nameId() + "|" + a.dayTrack() + "|" + a.nightTrack() + "|" + a.combatTrack(); + String triggersJson = TriggerIO.serializeList(a.triggers()); + input.selectedAreaInfo = idx + "|" + a.areaId() + "|" + triggersJson; input.areaSelectionChanged = true; + if (input.activeLayer == SharedInput.LAYER_ZONEN) { + input.zonenSelectedKind = "area"; + input.zonenSelectionChanged = true; + } } // ── Add / Remove / Apply ────────────────────────────────────────────────── @@ -303,9 +350,9 @@ public class AreaState extends BaseAppState { private void applyProperty(int idx, PlacedArea updated) { if (updated.pointsX().length == 0) { PlacedArea existing = areas.get(idx); - areas.set(idx, new PlacedArea( - existing.pointsX(), existing.pointsZ(), - updated.nameId(), updated.dayTrack(), updated.nightTrack(), updated.combatTrack())); + areas.set(idx, new PlacedArea(existing.pointsX(), existing.pointsZ(), + updated.areaId(), + updated.triggers() != null ? updated.triggers() : existing.triggers())); } else { areas.set(idx, updated); } @@ -317,7 +364,7 @@ public class AreaState extends BaseAppState { int n = xs.size(); FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3); for (int i = 0; i < n; i++) { - float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f; + float hy = getHeightAt(xs.get(i), zs.get(i)); posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i)); } posBuffer.flip(); @@ -329,13 +376,29 @@ public class AreaState extends BaseAppState { Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); mat.setColor("Color", color); - mat.getAdditionalRenderState().setLineWidth(2f); + mat.getAdditionalRenderState().setLineWidth(4f); Geometry geo = new Geometry(name, mesh); geo.setMaterial(mat); return geo; } + public boolean isPlacing() { return placing; } + + void deselectSilent() { + if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) { + areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + areaGeos.get(selectedIdx).getMaterial().getAdditionalRenderState().setLineWidth(4f); + } + selectedIdx = -1; + input.selectedAreaInfo = null; + input.areaSelectionChanged = true; + if ("area".equals(input.zonenSelectedKind)) { + input.zonenSelectedKind = null; + input.zonenSelectionChanged = true; + } + } + // ── Overlap detection ───────────────────────────────────────────────────── private boolean overlapsExistingAreas(float[] xs, float[] zs) { @@ -383,6 +446,15 @@ public class AreaState extends BaseAppState { return new ArrayList<>(areas); } + public int findZoneAt(float wx, float wz) { + for (int i = 0; i < areas.size(); i++) { + PlacedArea a = areas.get(i); + if (SoundAreaState.pointInPolygon(wx, wz, a.pointsX(), a.pointsZ()) + || SoundAreaState.pointNearPolygonEdge(wx, wz, a.pointsX(), a.pointsZ(), 0.5f)) return i; + } + return -1; + } + public void loadAreas(List loaded) { if (rootNode == null) { pendingAreas = new ArrayList<>(loaded); @@ -405,4 +477,66 @@ public class AreaState extends BaseAppState { for (float f : arr) l.add(f); return l; } + + private Vector3f raycastAll(Ray ray) { + Vector3f best = null; + float bestDistSq = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() > 0) { + best = hits.getClosestCollision().getContactPoint(); + bestDistSq = ray.getOrigin().distanceSquared(best); + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = ray.getOrigin().distanceSquared(vp); + if (d < bestDistSq) { best = vp; bestDistSq = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = ray.getOrigin().distanceSquared(sp); + if (d < bestDistSq) { best = sp; } + } + } + return best; + } + + private float getHeightAt(float wx, float wz) { + Ray ray = new Ray(new Vector3f(wx, 9999f, wz), new Vector3f(0f, -1f, 0f)); + float best = 0f; + float bestDistFromTop = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults res = new CollisionResults(); + terrain.collideWith(ray, res); + if (res.size() > 0) { + float y = res.getClosestCollision().getContactPoint().y; + float d = 9999f - y; + if (d < bestDistFromTop) { best = y; bestDistFromTop = d; } + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = 9999f - vp.y; + if (d < bestDistFromTop) { best = vp.y; bestDistFromTop = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = 9999f - sp.y; + if (d < bestDistFromTop) { best = sp.y; } + } + } + return best; + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/EmitterState.java b/blight-editor/src/main/java/de/blight/editor/state/EmitterState.java index 893b396..5bcb6ae 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/EmitterState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/EmitterState.java @@ -119,11 +119,8 @@ public class EmitterState extends BaseAppState { if (click.rightButton()) { deselect(); return; } - if (terrain == null) return; - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() == 0) return; - Vector3f pt = hits.getClosestCollision().getContactPoint(); + Vector3f pt = raycastAll(ray); + if (pt == null) return; PlacedEmitter pe = createPreset(input.emitterPreset, pt.x, pt.y, pt.z); addEmitter(pe); @@ -327,4 +324,34 @@ public class EmitterState extends BaseAppState { clearAll(); for (PlacedEmitter pe : loaded) addEmitter(pe); } + + private Vector3f raycastAll(Ray ray) { + Vector3f best = null; + float bestDistSq = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() > 0) { + best = hits.getClosestCollision().getContactPoint(); + bestDistSq = ray.getOrigin().distanceSquared(best); + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = ray.getOrigin().distanceSquared(vp); + if (d < bestDistSq) { best = vp; bestDistSq = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = ray.getOrigin().distanceSquared(sp); + if (d < bestDistSq) { best = sp; } + } + } + return best; + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/LightState.java b/blight-editor/src/main/java/de/blight/editor/state/LightState.java index 58a00e1..71a0ed1 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/LightState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/LightState.java @@ -129,12 +129,8 @@ public class LightState extends BaseAppState { return; } - // Neues Licht auf dem Terrain platzieren - if (terrain == null) return; - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() == 0) return; - Vector3f pt = hits.getClosestCollision().getContactPoint(); + Vector3f pt = raycastAll(ray); + if (pt == null) return; PlacedLight pl = new PlacedLight(pt.x, pt.y + 1f, pt.z, 1f, 1f, 1f, 1f, 20f); addLight(pl); @@ -317,4 +313,34 @@ public class LightState extends BaseAppState { addLight(pl); } } + + private Vector3f raycastAll(Ray ray) { + Vector3f best = null; + float bestDistSq = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() > 0) { + best = hits.getClosestCollision().getContactPoint(); + bestDistSq = ray.getOrigin().distanceSquared(best); + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = ray.getOrigin().distanceSquared(vp); + if (d < bestDistSq) { best = vp; bestDistSq = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = ray.getOrigin().distanceSquared(sp); + if (d < bestDistSq) { best = sp; } + } + } + return best; + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/LocationZoneState.java b/blight-editor/src/main/java/de/blight/editor/state/LocationZoneState.java index 5a5df16..90a2f41 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/LocationZoneState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/LocationZoneState.java @@ -23,9 +23,9 @@ public class LocationZoneState extends BaseAppState { private static final float LINE_OFFSET_Y = 0.40f; - private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.2f, 0.8f, 0.4f, 1f); - private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 0.9f, 0.2f, 1f); - private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.5f, 1f, 0.6f, 1f); + private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(1.00f, 0.85f, 0.00f, 1f); // Gelb + private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1.00f, 1.00f, 0.55f, 1f); // Hell-Gelb + private static final ColorRGBA COLOR_INPROG = new ColorRGBA(1.00f, 0.95f, 0.30f, 1f); // Mittel-Gelb private final SharedInput input; private SimpleApplication app; @@ -78,7 +78,7 @@ public class LocationZoneState extends BaseAppState { @Override public void update(float tpf) { - if (input.activeLayer != SharedInput.LAYER_LOCATION_ZONES) { + if (input.activeLayer != SharedInput.LAYER_LOCATION_ZONES && input.activeLayer != SharedInput.LAYER_ZONEN) { if (placing) cancelPoly(); return; } @@ -93,9 +93,9 @@ public class LocationZoneState extends BaseAppState { applyProperty(selectedIdx, pending); } - if (input.cancelZoneDrawing) { + if (input.cancelZoneDrawing && placing) { input.cancelZoneDrawing = false; - if (placing) cancelPoly(); + cancelPoly(); } if (input.deleteLocationZoneRequested) { @@ -115,16 +115,13 @@ public class LocationZoneState extends BaseAppState { Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); if (click.rightButton()) { - if (placing) closePoly(); + if (placing) undoLastPoint(); else deselect(); return; } - if (terrain == null) return; - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() == 0) return; - Vector3f pt = hits.getClosestCollision().getContactPoint(); + Vector3f pt = raycastAll(ray); + if (pt == null) return; float hitX = pt.x, hitZ = pt.z; if (placing) { @@ -132,10 +129,11 @@ public class LocationZoneState extends BaseAppState { hitX = snapped[0]; hitZ = snapped[1]; + // auto-close only when snapped exactly to own first vertex if (currX.size() >= 3) { float dx = hitX - currX.get(0); float dz = hitZ - currZ.get(0); - if (dx * dx + dz * dz < SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST * 0.25f) { + if (dx * dx + dz * dz < 0.01f) { closePoly(); return; } @@ -145,18 +143,40 @@ public class LocationZoneState extends BaseAppState { currZ.add(hitZ); updateInProgressGeo(); } else { + // don't select while another state is drawing + AreaState as0 = getStateManager().getState(AreaState.class); + SoundAreaState sas0 = getStateManager().getState(SoundAreaState.class); + if ((as0 != null && as0.isPlacing()) || (sas0 != null && sas0.isPlacing())) return; + + // pass 1: clearly inside for (int i = 0; i < zones.size(); i++) { PlacedLocationZone z = zones.get(i); if (SoundAreaState.pointInPolygon(hitX, hitZ, z.pointsX(), z.pointsZ())) { - selectZone(i); - return; + selectZone(i); return; } } + // pass 2: within 50 cm of any edge + for (int i = 0; i < zones.size(); i++) { + PlacedLocationZone z = zones.get(i); + if (SoundAreaState.pointNearPolygonEdge(hitX, hitZ, z.pointsX(), z.pointsZ(), 0.5f)) { + selectZone(i); return; + } + } + // cross-type: if another zone type is here, don't start drawing + if (input.activeLayer == SharedInput.LAYER_ZONEN) { + AreaState as = getStateManager().getState(AreaState.class); + if (as != null && as.findZoneAt(hitX, hitZ) >= 0) return; + SoundAreaState sas = getStateManager().getState(SoundAreaState.class); + if (sas != null && sas.findZoneAt(hitX, hitZ) >= 0) return; + } deselect(); + if (input.activeLayer == SharedInput.LAYER_ZONEN && !"location".equals(input.zonenNewKind)) return; placing = true; currX.clear(); currZ.clear(); - currX.add(hitX); + // snap first point to nearby existing vertices + float[] startSnap = snapVertex(hitX, hitZ); + currX.add(startSnap[0]); currZ.add(hitZ); updateInProgressGeo(); } @@ -189,6 +209,7 @@ public class LocationZoneState extends BaseAppState { PlacedLocationZone zone = new PlacedLocationZone(xs, zs, ""); addZone(zone); selectZone(zones.size() - 1); + if (input.activeLayer == SharedInput.LAYER_ZONEN) input.zonenNewKind = null; cancelPoly(); } @@ -200,6 +221,14 @@ public class LocationZoneState extends BaseAppState { if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; } } + private void undoLastPoint() { + if (currX.isEmpty()) { cancelPoly(); return; } + currX.remove(currX.size() - 1); + currZ.remove(currZ.size() - 1); + if (currX.isEmpty()) cancelPoly(); + else updateInProgressGeo(); + } + private void updateInProgressGeo() { if (inProgGeo != null) rootNode.detachChild(inProgGeo); int n = currX.size(); @@ -215,7 +244,7 @@ public class LocationZoneState extends BaseAppState { float x = currX.get(currX.size() - 1); float z = currZ.get(currZ.size() - 1); - float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f; + float y = getHeightAt(x, z) + LINE_OFFSET_Y + 0.05f; float s = 1.5f; FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3); @@ -242,19 +271,31 @@ public class LocationZoneState extends BaseAppState { private void selectZone(int idx) { if (selectedIdx >= 0 && selectedIdx < zoneGeos.size()) { zoneGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + zoneGeos.get(selectedIdx).getMaterial().getAdditionalRenderState().setLineWidth(6f); } + // cross-state deselect + AreaState as = getStateManager().getState(AreaState.class); + if (as != null) as.deselectSilent(); + SoundAreaState sas = getStateManager().getState(SoundAreaState.class); + if (sas != null) sas.deselectSilent(); selectedIdx = idx; zoneGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED); + zoneGeos.get(idx).getMaterial().getAdditionalRenderState().setLineWidth(10f); publishSelection(idx); } private void deselect() { if (selectedIdx >= 0 && selectedIdx < zoneGeos.size()) { zoneGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + zoneGeos.get(selectedIdx).getMaterial().getAdditionalRenderState().setLineWidth(6f); } selectedIdx = -1; input.selectedLocationZoneInfo = null; input.locationZoneSelectionChanged = true; + if ("location".equals(input.zonenSelectedKind)) { + input.zonenSelectedKind = null; + input.zonenSelectionChanged = true; + } } private void publishSelection(int idx) { @@ -262,6 +303,10 @@ public class LocationZoneState extends BaseAppState { String triggersJson = de.blight.common.model.trigger.TriggerIO.serializeList(z.triggers()); input.selectedLocationZoneInfo = idx + "|" + z.nameId() + "|" + triggersJson; input.locationZoneSelectionChanged = true; + if (input.activeLayer == SharedInput.LAYER_ZONEN) { + input.zonenSelectedKind = "location"; + input.zonenSelectionChanged = true; + } } // ── Add / Remove / Apply ────────────────────────────────────────────────── @@ -310,7 +355,7 @@ public class LocationZoneState extends BaseAppState { int n = xs.size(); FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3); for (int i = 0; i < n; i++) { - float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f; + float hy = getHeightAt(xs.get(i), zs.get(i)); posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i)); } posBuffer.flip(); @@ -322,19 +367,44 @@ public class LocationZoneState extends BaseAppState { Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); mat.setColor("Color", color); - mat.getAdditionalRenderState().setLineWidth(2f); + mat.getAdditionalRenderState().setLineWidth(6f); Geometry geo = new Geometry(name, mesh); geo.setMaterial(mat); return geo; } + public boolean isPlacing() { return placing; } + + void deselectSilent() { + if (selectedIdx >= 0 && selectedIdx < zoneGeos.size()) { + zoneGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + zoneGeos.get(selectedIdx).getMaterial().getAdditionalRenderState().setLineWidth(6f); + } + selectedIdx = -1; + input.selectedLocationZoneInfo = null; + input.locationZoneSelectionChanged = true; + if ("location".equals(input.zonenSelectedKind)) { + input.zonenSelectedKind = null; + input.zonenSelectionChanged = true; + } + } + // ── Save / Load ─────────────────────────────────────────────────────────── public List getPlacedZones() { return new ArrayList<>(zones); } + public int findZoneAt(float wx, float wz) { + for (int i = 0; i < zones.size(); i++) { + PlacedLocationZone z = zones.get(i); + if (SoundAreaState.pointInPolygon(wx, wz, z.pointsX(), z.pointsZ()) + || SoundAreaState.pointNearPolygonEdge(wx, wz, z.pointsX(), z.pointsZ(), 0.5f)) return i; + } + return -1; + } + public void loadZones(List loaded) { if (rootNode == null) { pendingZones = new ArrayList<>(loaded); @@ -357,4 +427,66 @@ public class LocationZoneState extends BaseAppState { for (float f : arr) l.add(f); return l; } + + private Vector3f raycastAll(Ray ray) { + Vector3f best = null; + float bestDistSq = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() > 0) { + best = hits.getClosestCollision().getContactPoint(); + bestDistSq = ray.getOrigin().distanceSquared(best); + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = ray.getOrigin().distanceSquared(vp); + if (d < bestDistSq) { best = vp; bestDistSq = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = ray.getOrigin().distanceSquared(sp); + if (d < bestDistSq) { best = sp; } + } + } + return best; + } + + private float getHeightAt(float wx, float wz) { + Ray ray = new Ray(new Vector3f(wx, 9999f, wz), new Vector3f(0f, -1f, 0f)); + float best = 0f; + float bestDistFromTop = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults res = new CollisionResults(); + terrain.collideWith(ray, res); + if (res.size() > 0) { + float y = res.getClosestCollision().getContactPoint().y; + float d = 9999f - y; + if (d < bestDistFromTop) { best = y; bestDistFromTop = d; } + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = 9999f - vp.y; + if (d < bestDistFromTop) { best = vp.y; bestDistFromTop = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = 9999f - sp.y; + if (d < bestDistFromTop) { best = sp.y; } + } + } + return best; + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java index 5db5fa0..56226e0 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java @@ -859,7 +859,7 @@ public class SceneObjectState extends BaseAppState { al.r(), al.g(), al.b(), al.intensity(), al.radius())); } de.blight.common.LightIO.save(list); - input.reloadPlacedOther = true; + input.reloadLightsEmitters = true; } catch (java.io.IOException e) { log.error("[SceneObject] Anhang-Licht speichern fehlgeschlagen: {}", e.getMessage()); } @@ -883,7 +883,7 @@ public class SceneObjectState extends BaseAppState { list.add(pe); } de.blight.common.EmitterIO.save(list); - input.reloadPlacedOther = true; + input.reloadLightsEmitters = true; } catch (java.io.IOException e) { log.error("[SceneObject] Anhang-Emitter speichern fehlgeschlagen: {}", e.getMessage()); } diff --git a/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java index 66e3d89..79c79e1 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java @@ -850,6 +850,7 @@ public class SculptedMeshEditorState extends BaseAppState { /** Raycast gegen alle gebackenen Voxel-Meshes; gibt den nächsten Treffpunkt oder null zurück. */ public com.jme3.math.Vector3f raycastGeometry(com.jme3.math.Ray ray) { + if (sculptRoot == null) return null; CollisionResults cr = new CollisionResults(); sculptRoot.collideWith(ray, cr); return cr.size() > 0 ? cr.getClosestCollision().getContactPoint() : null; diff --git a/blight-editor/src/main/java/de/blight/editor/state/SoundAreaState.java b/blight-editor/src/main/java/de/blight/editor/state/SoundAreaState.java index 6b4bc31..3c456e0 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SoundAreaState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SoundAreaState.java @@ -26,9 +26,9 @@ public class SoundAreaState extends BaseAppState { static final float SNAP_DIST = 8f; private static final float LINE_OFFSET_Y = 0.3f; - private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.1f, 0.85f, 0.4f, 1f); - private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 1f, 0f, 1f); - private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.3f, 0.9f, 1f, 1f); + private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(1.00f, 0.50f, 0.05f, 1f); // Orange + private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1.00f, 0.75f, 0.45f, 1f); // Hell-Orange + private static final ColorRGBA COLOR_INPROG = new ColorRGBA(1.00f, 0.65f, 0.20f, 1f); // Mittel-Orange private final SharedInput input; private SimpleApplication app; @@ -82,7 +82,7 @@ public class SoundAreaState extends BaseAppState { @Override public void update(float tpf) { - if (input.activeLayer != SharedInput.LAYER_SOUND_AREAS) { + if (input.activeLayer != SharedInput.LAYER_SOUND_AREAS && input.activeLayer != SharedInput.LAYER_ZONEN) { if (placing) cancelPoly(); return; } @@ -97,9 +97,9 @@ public class SoundAreaState extends BaseAppState { applyProperty(selectedIdx, pending); } - if (input.cancelZoneDrawing) { + if (input.cancelZoneDrawing && placing) { input.cancelZoneDrawing = false; - if (placing) cancelPoly(); + cancelPoly(); } if (input.deleteSoundAreaRequested) { @@ -119,30 +119,25 @@ public class SoundAreaState extends BaseAppState { Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); if (click.rightButton()) { - if (placing) closePoly(); + if (placing) undoLastPoint(); else deselect(); return; } - // get terrain hit - if (terrain == null) return; - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() == 0) return; - Vector3f pt = hits.getClosestCollision().getContactPoint(); + Vector3f pt = raycastAll(ray); + if (pt == null) return; float hitX = pt.x, hitZ = pt.z; if (placing) { - // snap to existing vertex? float[] snapped = snapVertex(hitX, hitZ); hitX = snapped[0]; hitZ = snapped[1]; - // auto-close: if close to first vertex and ≥3 points + // auto-close only when snapped exactly to own first vertex if (currX.size() >= 3) { float dx = hitX - currX.get(0); float dz = hitZ - currZ.get(0); - if (dx * dx + dz * dz < SNAP_DIST * SNAP_DIST * 0.25f) { + if (dx * dx + dz * dz < 0.01f) { closePoly(); return; } @@ -152,21 +147,41 @@ public class SoundAreaState extends BaseAppState { currZ.add(hitZ); updateInProgressGeo(); } else { - // try to select existing area + // don't select while another state is drawing + AreaState as0 = getStateManager().getState(AreaState.class); + LocationZoneState lzs0 = getStateManager().getState(LocationZoneState.class); + if ((as0 != null && as0.isPlacing()) || (lzs0 != null && lzs0.isPlacing())) return; + + // pass 1: clearly inside for (int i = 0; i < areas.size(); i++) { PlacedSoundArea a = areas.get(i); if (pointInPolygon(hitX, hitZ, a.pointsX(), a.pointsZ())) { - selectArea(i); - return; + selectArea(i); return; } } - // no area hit – start new polygon + // pass 2: within 50 cm of any edge + for (int i = 0; i < areas.size(); i++) { + PlacedSoundArea a = areas.get(i); + if (pointNearPolygonEdge(hitX, hitZ, a.pointsX(), a.pointsZ(), 0.5f)) { + selectArea(i); return; + } + } + // cross-type: if another zone type is here, don't start drawing + if (input.activeLayer == SharedInput.LAYER_ZONEN) { + AreaState as = getStateManager().getState(AreaState.class); + if (as != null && as.findZoneAt(hitX, hitZ) >= 0) return; + LocationZoneState lzs = getStateManager().getState(LocationZoneState.class); + if (lzs != null && lzs.findZoneAt(hitX, hitZ) >= 0) return; + } deselect(); + if (input.activeLayer == SharedInput.LAYER_ZONEN && !"sound".equals(input.zonenNewKind)) return; placing = true; currX.clear(); currZ.clear(); - currX.add(hitX); - currZ.add(hitZ); + // snap first point to nearby existing vertices + float[] startSnap = snapVertex(hitX, hitZ); + currX.add(startSnap[0]); + currZ.add(startSnap[1]); updateInProgressGeo(); } } @@ -201,6 +216,7 @@ public class SoundAreaState extends BaseAppState { PlacedSoundArea area = new PlacedSoundArea(xs, zs, "", 1f, false); addArea(area); selectArea(areas.size() - 1); + if (input.activeLayer == SharedInput.LAYER_ZONEN) input.zonenNewKind = null; cancelPoly(); } @@ -212,6 +228,14 @@ public class SoundAreaState extends BaseAppState { if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; } } + private void undoLastPoint() { + if (currX.isEmpty()) { cancelPoly(); return; } + currX.remove(currX.size() - 1); + currZ.remove(currZ.size() - 1); + if (currX.isEmpty()) cancelPoly(); + else updateInProgressGeo(); + } + // ── In-progress visual ──────────────────────────────────────────────────── private void updateInProgressGeo() { @@ -229,7 +253,7 @@ public class SoundAreaState extends BaseAppState { float x = currX.get(currX.size() - 1); float z = currZ.get(currZ.size() - 1); - float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f; + float y = getHeightAt(x, z) + LINE_OFFSET_Y + 0.05f; float s = 1.5f; FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3); @@ -256,25 +280,41 @@ public class SoundAreaState extends BaseAppState { private void selectArea(int idx) { if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) { areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + areaGeos.get(selectedIdx).getMaterial().getAdditionalRenderState().setLineWidth(4f); } + // cross-state deselect + AreaState as = getStateManager().getState(AreaState.class); + if (as != null) as.deselectSilent(); + LocationZoneState lzs = getStateManager().getState(LocationZoneState.class); + if (lzs != null) lzs.deselectSilent(); selectedIdx = idx; areaGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED); + areaGeos.get(idx).getMaterial().getAdditionalRenderState().setLineWidth(8f); publishSelection(idx); } private void deselect() { if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) { areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + areaGeos.get(selectedIdx).getMaterial().getAdditionalRenderState().setLineWidth(4f); } selectedIdx = -1; input.selectedSoundAreaInfo = null; input.soundAreaSelectionChanged = true; + if ("sound".equals(input.zonenSelectedKind)) { + input.zonenSelectedKind = null; + input.zonenSelectionChanged = true; + } } private void publishSelection(int idx) { PlacedSoundArea a = areas.get(idx); input.selectedSoundAreaInfo = idx + "|" + a.soundPath() + "|" + a.volume() + "|" + a.crossfade(); input.soundAreaSelectionChanged = true; + if (input.activeLayer == SharedInput.LAYER_ZONEN) { + input.zonenSelectedKind = "sound"; + input.zonenSelectionChanged = true; + } } // ── Add / Remove / Apply ────────────────────────────────────────────────── @@ -325,7 +365,7 @@ public class SoundAreaState extends BaseAppState { int n = xs.size(); FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3); for (int i = 0; i < n; i++) { - float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f; + float hy = getHeightAt(xs.get(i), zs.get(i)); posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i)); } posBuffer.flip(); @@ -337,19 +377,44 @@ public class SoundAreaState extends BaseAppState { Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); mat.setColor("Color", color); - mat.getAdditionalRenderState().setLineWidth(2f); + mat.getAdditionalRenderState().setLineWidth(4f); Geometry geo = new Geometry(name, mesh); geo.setMaterial(mat); return geo; } + public boolean isPlacing() { return placing; } + + void deselectSilent() { + if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) { + areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + areaGeos.get(selectedIdx).getMaterial().getAdditionalRenderState().setLineWidth(4f); + } + selectedIdx = -1; + input.selectedSoundAreaInfo = null; + input.soundAreaSelectionChanged = true; + if ("sound".equals(input.zonenSelectedKind)) { + input.zonenSelectedKind = null; + input.zonenSelectionChanged = true; + } + } + // ── Save / Load ─────────────────────────────────────────────────────────── public List getPlacedAreas() { return new ArrayList<>(areas); } + public int findZoneAt(float wx, float wz) { + for (int i = 0; i < areas.size(); i++) { + PlacedSoundArea a = areas.get(i); + if (pointInPolygon(wx, wz, a.pointsX(), a.pointsZ()) + || pointNearPolygonEdge(wx, wz, a.pointsX(), a.pointsZ(), 0.5f)) return i; + } + return -1; + } + public void loadAreas(List loaded) { if (rootNode == null) { pendingAreas = new ArrayList<>(loaded); @@ -359,7 +424,28 @@ public class SoundAreaState extends BaseAppState { for (PlacedSoundArea a : loaded) addArea(a); } - // ── Point-in-polygon (ray casting) ──────────────────────────────────────── + // ── Point-in-polygon + edge proximity ──────────────────────────────────── + + static boolean pointNearPolygonEdge(float px, float pz, float[] xs, float[] zs, float threshold) { + int n = xs.length; + float t2 = threshold * threshold; + for (int i = 0; i < n; i++) { + int j = (i + 1) % n; + float ax = xs[i], az = zs[i], bx = xs[j], bz = zs[j]; + float dx = bx - ax, dz = bz - az; + float lenSq = dx * dx + dz * dz; + float t; + if (lenSq < 0.0001f) { + t = 0f; + } else { + t = ((px - ax) * dx + (pz - az) * dz) / lenSq; + t = Math.max(0f, Math.min(1f, t)); + } + float nx = ax + t * dx - px, nz = az + t * dz - pz; + if (nx * nx + nz * nz < t2) return true; + } + return false; + } static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) { int n = xs.length; @@ -387,4 +473,66 @@ public class SoundAreaState extends BaseAppState { for (float f : arr) l.add(f); return l; } + + private Vector3f raycastAll(Ray ray) { + Vector3f best = null; + float bestDistSq = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() > 0) { + best = hits.getClosestCollision().getContactPoint(); + bestDistSq = ray.getOrigin().distanceSquared(best); + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = ray.getOrigin().distanceSquared(vp); + if (d < bestDistSq) { best = vp; bestDistSq = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = ray.getOrigin().distanceSquared(sp); + if (d < bestDistSq) { best = sp; } + } + } + return best; + } + + private float getHeightAt(float wx, float wz) { + Ray ray = new Ray(new Vector3f(wx, 9999f, wz), new Vector3f(0f, -1f, 0f)); + float best = 0f; + float bestDistFromTop = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults res = new CollisionResults(); + terrain.collideWith(ray, res); + if (res.size() > 0) { + float y = res.getClosestCollision().getContactPoint().y; + float d = 9999f - y; + if (d < bestDistFromTop) { best = y; bestDistFromTop = d; } + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = 9999f - vp.y; + if (d < bestDistFromTop) { best = vp.y; bestDistFromTop = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = 9999f - sp.y; + if (d < bestDistFromTop) { best = sp.y; } + } + } + return best; + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/StoneEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/StoneEditorState.java index f9eaadf..86caccc 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/StoneEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/StoneEditorState.java @@ -195,9 +195,8 @@ public class StoneEditorState extends BaseAppState { } 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; } + if (pendingStone == null) { return; } + if (!Float.isFinite(getHeightAt(wx, wz))) { return; } PlacedStone stone = new PlacedStone(wx, wz, pendingStone.radius(), pendingStone.rotY(), pendingStone.textureSlot(), pendingStone.sinkFraction(), pendingStone.noiseSeed()); int ci = chunkIndex(wx, wz); @@ -247,8 +246,7 @@ public class StoneEditorState extends BaseAppState { float sx = wx + (float)(Math.cos(angle) * dist); float sz = wz + (float)(Math.sin(angle) * dist); - float th = terrain.getHeight(new Vector2f(sx, sz)); - if (!Float.isFinite(th)) continue; + if (!Float.isFinite(getHeightAt(sx, sz))) continue; float radius = (float)(minR + random.nextDouble() * (maxR - minR)); float rotY = random.nextFloat() * 360f; @@ -605,12 +603,71 @@ public class StoneEditorState extends BaseAppState { private Vector3f raycastTerrain(float sx, float sy) { if (terrain == null) return null; - Ray ray = new Ray(cam.getWorldCoordinates(new Vector2f(sx, sy), 0f), - cam.getWorldCoordinates(new Vector2f(sx, sy), 1f)); - ray.getDirection().subtractLocal(ray.getOrigin()).normalizeLocal(); + Vector3f origin = cam.getWorldCoordinates(new Vector2f(sx, sy), 0f); + Vector3f target = cam.getWorldCoordinates(new Vector2f(sx, sy), 1f); + Ray ray = new Ray(origin, target.subtractLocal(origin).normalizeLocal()); + CollisionResults res = new CollisionResults(); terrain.collideWith(ray, res); - return res.size() > 0 ? res.getClosestCollision().getContactPoint() : null; + Vector3f best = res.size() > 0 ? res.getClosestCollision().getContactPoint() : null; + float bestDistSq = best != null ? origin.distanceSquared(best) : Float.MAX_VALUE; + + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = origin.distanceSquared(vp); + if (d < bestDistSq) { best = vp; bestDistSq = d; } + } + } + + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = origin.distanceSquared(sp); + if (d < bestDistSq) { best = sp; } + } + } + + return best; + } + + /** Höhe an (wx, wz): nimmt das höchste Ergebnis aus Terrain, Voxeln und Sculpted Mesh. */ + private float getHeightAt(float wx, float wz) { + Ray ray = new Ray(new Vector3f(wx, 9999f, wz), new Vector3f(0f, -1f, 0f)); + float best = Float.NaN; + float bestDistFromTop = Float.MAX_VALUE; + + if (terrain != null) { + CollisionResults res = new CollisionResults(); + terrain.collideWith(ray, res); + if (res.size() > 0) { + float y = res.getClosestCollision().getContactPoint().y; + float d = 9999f - y; + if (d < bestDistFromTop) { best = y; bestDistFromTop = d; } + } + } + + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = 9999f - vp.y; + if (d < bestDistFromTop) { best = vp.y; bestDistFromTop = d; } + } + } + + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = 9999f - sp.y; + if (d < bestDistFromTop) { best = sp.y; } + } + } + + return best; } // ── Hilfsmethoden ──────────────────────────────────────────────────────── @@ -631,10 +688,8 @@ public class StoneEditorState extends BaseAppState { } private float stoneWorldY(PlacedStone s) { - if (terrain == null) return 0f; - float h = terrain.getHeight(new Vector2f(s.x(), s.z())); - if (!Float.isFinite(h)) return 0f; - return h < -1e10f ? 0f : h; + float h = getHeightAt(s.x(), s.z()); + return Float.isFinite(h) ? h : 0f; } // ── Persistenz ─────────────────────────────────────────────────────────── diff --git a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java index 4e7964d..24cc7aa 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java @@ -1062,6 +1062,12 @@ public class TerrainEditorState extends BaseAppState { terrain.setMaterial(terrainMat = buildTerrainMaterial()); } + if (input.reloadLightsEmitters) { + input.reloadLightsEmitters = false; + try { if (lightState != null) lightState.loadPlacedLights(de.blight.common.LightIO.load()); } catch (Exception ignored) {} + try { if (emitterState != null) emitterState.loadPlacedEmitters(de.blight.common.EmitterIO.load()); } catch (Exception ignored) {} + } + if (input.reloadPlacedOther) { input.reloadPlacedOther = false; try { if (lightState != null) lightState.loadPlacedLights(de.blight.common.LightIO.load()); } catch (Exception ignored) {} diff --git a/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java index f023bdf..a986794 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java @@ -1333,6 +1333,7 @@ public class VoxelEditorState extends BaseAppState { /** Raycast gegen reine Voxel-Geometrie; gibt nächsten Treffpunkt oder null zurück. */ public Vector3f raycastVoxelGeometry(com.jme3.math.Ray ray) { + if (voxelRoot == null) return null; CollisionResults results = new CollisionResults(); voxelRoot.collideWith(ray, results); if (results.size() == 0) return null; diff --git a/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java b/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java index ff1d98f..fd1717f 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java @@ -165,11 +165,8 @@ public class WaterBodyState extends BaseAppState { return; } - if (terrain == null) return; - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() == 0) return; - Vector3f pt = hits.getClosestCollision().getContactPoint(); + Vector3f pt = raycastAll(ray); + if (pt == null) return; float hitX = pt.x, hitZ = pt.z; if (placing) { @@ -273,15 +270,14 @@ public class WaterBodyState extends BaseAppState { private void updatePillarGeo() { if (pillarGeo != null) { rootNode.detachChild(pillarGeo); pillarGeo = null; } int n = currX.size(); - if (n == 0 || terrain == null) return; + if (n == 0) return; FloatBuffer pos = BufferUtils.createFloatBuffer(n * 2 * 3); FloatBuffer col = BufferUtils.createFloatBuffer(n * 2 * 4); for (int i = 0; i < n; i++) { float x = currX.get(i), z = currZ.get(i); - Float th = terrain.getHeight(new com.jme3.math.Vector2f(x, z)); - float ty = th != null ? th : currentWaterHeight; + float ty = getHeightAt(x, z); float wy = currentWaterHeight; ColorRGBA c = (ty < wy) ? PILLAR_SUB : PILLAR_DRY; @@ -307,18 +303,15 @@ public class WaterBodyState extends BaseAppState { } private void updateCursorPillar() { - if (terrain == null || input.mouseScreenX < 0) { removeCursorPillar(); return; } + if (input.mouseScreenX < 0) { removeCursorPillar(); return; } float jmeX = input.mouseScreenX * (float) input.viewportScaleX; float jmeY = cam.getHeight() - input.mouseScreenY * (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) { removeCursorPillar(); return; } - - Vector3f pt = hits.getClosestCollision().getContactPoint(); + Vector3f pt = raycastAll(ray); + if (pt == null) { removeCursorPillar(); return; } float ty = pt.y, wy = currentWaterHeight; float delta = wy - ty; ColorRGBA c = (delta > 0) ? PILLAR_SUB : PILLAR_DRY; @@ -432,16 +425,13 @@ public class WaterBodyState extends BaseAppState { // ── Height sampling ─────────────────────────────────────────────────────── private float sampleTerrainAtCursor() { - if (terrain == null) return Float.NaN; float jmeX = input.mouseScreenX * (float) input.viewportScaleX; float jmeY = cam.getHeight() - input.mouseScreenY * (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 Float.NaN; - return hits.getClosestCollision().getContactPoint().y; + Vector3f pt = raycastAll(ray); + return pt != null ? pt.y : Float.NaN; } // ── Selection ───────────────────────────────────────────────────────────── @@ -639,4 +629,66 @@ public class WaterBodyState extends BaseAppState { for (float f : arr) l.add(f); return l; } + + private Vector3f raycastAll(Ray ray) { + Vector3f best = null; + float bestDistSq = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() > 0) { + best = hits.getClosestCollision().getContactPoint(); + bestDistSq = ray.getOrigin().distanceSquared(best); + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = ray.getOrigin().distanceSquared(vp); + if (d < bestDistSq) { best = vp; bestDistSq = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = ray.getOrigin().distanceSquared(sp); + if (d < bestDistSq) { best = sp; } + } + } + return best; + } + + private float getHeightAt(float wx, float wz) { + Ray ray = new Ray(new Vector3f(wx, 9999f, wz), new Vector3f(0f, -1f, 0f)); + float best = 0f; + float bestDistFromTop = Float.MAX_VALUE; + if (terrain != null) { + CollisionResults res = new CollisionResults(); + terrain.collideWith(ray, res); + if (res.size() > 0) { + float y = res.getClosestCollision().getContactPoint().y; + float d = 9999f - y; + if (d < bestDistFromTop) { best = y; bestDistFromTop = d; } + } + } + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null) { + Vector3f vp = ves.raycastVoxelGeometry(ray); + if (vp != null) { + float d = 9999f - vp.y; + if (d < bestDistFromTop) { best = vp.y; bestDistFromTop = d; } + } + } + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f sp = smes.raycastGeometry(ray); + if (sp != null) { + float d = 9999f - sp.y; + if (d < bestDistFromTop) { best = sp.y; } + } + } + return best; + } } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/AreaEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/AreaEditorView.java new file mode 100644 index 0000000..0383f9a --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/AreaEditorView.java @@ -0,0 +1,289 @@ +package de.blight.editor.ui; + +import de.blight.common.AreaDefinition; +import de.blight.common.AreaDefinitionIO; +import de.blight.editor.ProjectRoot; +import javafx.animation.PauseTransition; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.util.Duration; + +import java.io.IOException; +import java.nio.file.Path; + +public class AreaEditorView extends BorderPane { + + private static final String PREFIX = "area."; + private static final String[] TRACK_NAMES = {"☀ Tag-Track", "🌙 Nacht-Track", "⚔ Kampf-Track"}; + + private final ObservableList areas = FXCollections.observableArrayList(); + private final PauseTransition savePause = new PauseTransition(Duration.millis(600)); + private final String[] tracks = {"", "", ""}; + + private ListView listView; + private Button deleteBtn; + private AreaDefinition current = null; + private boolean reloading = false; + + private TextField idField; + private Label nameKeyLabel; + private Label[] trackLabels = new Label[3]; + private VBox formContainer; + + { savePause.setOnFinished(e -> persistCurrent()); } + + public AreaEditorView() { + setStyle("-fx-background-color: #1e1e2e;"); + reload(); + + SplitPane split = new SplitPane(buildListPanel(), buildFormPanel()); + split.setDividerPositions(0.28); + setCenter(split); + } + + // ── Auto-save ───────────────────────────────────────────────────────────── + + private void scheduleSave() { savePause.playFromStart(); } + + private void persistCurrent() { + if (current == null || reloading) return; + AreaDefinition saved = formToArea(); + if (saved.id().isBlank()) return; + int idx = areas.indexOf(current); + if (idx < 0) return; + reloading = true; + areas.set(idx, saved); + current = saved; + reloading = false; + persist(); + } + + // ── List panel ──────────────────────────────────────────────────────────── + + private VBox buildListPanel() { + listView = new ListView<>(areas); + listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); + listView.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(AreaDefinition d, boolean empty) { + super.updateItem(d, empty); + if (empty || d == null) { setText(null); setStyle(""); return; } + String shortId = d.id().startsWith(PREFIX) ? d.id().substring(PREFIX.length()) : d.id(); + setText(shortId.isBlank() ? "—" : shortId); + setStyle("-fx-text-fill: #dddddd;" + + " -fx-border-color: transparent transparent transparent #cc6644;" + + " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;"); + } + }); + listView.getSelectionModel().selectedItemProperty() + .addListener((obs, old, nw) -> onSelected(old, nw)); + VBox.setVgrow(listView, Priority.ALWAYS); + + Button newBtn = new Button("Neue Area"); + newBtn.setMaxWidth(Double.MAX_VALUE); + newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;"); + newBtn.setOnAction(e -> createArea()); + + deleteBtn = new Button("Löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;"); + deleteBtn.setDisable(true); + deleteBtn.setOnAction(e -> deleteSelected()); + + Button refreshBtn = new Button("↺ Neu laden"); + refreshBtn.setMaxWidth(Double.MAX_VALUE); + refreshBtn.setOnAction(e -> reload()); + + HBox listButtons = new HBox(6, newBtn, deleteBtn); + HBox.setHgrow(newBtn, Priority.ALWAYS); + HBox.setHgrow(deleteBtn, Priority.ALWAYS); + listButtons.setPadding(new Insets(6, 8, 6, 8)); + + VBox panel = new VBox(6, listView, listButtons, refreshBtn); + VBox.setVgrow(listView, Priority.ALWAYS); + panel.setPadding(new Insets(8)); + panel.setStyle("-fx-background-color: #1a1a2a;"); + return panel; + } + + // ── Form panel ──────────────────────────────────────────────────────────── + + private ScrollPane buildFormPanel() { + formContainer = buildForm(); + formContainer.setDisable(true); + ScrollPane scroll = new ScrollPane(formContainer); + scroll.setFitToWidth(true); + scroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;"); + return scroll; + } + + private VBox buildForm() { + VBox form = new VBox(6); + form.setPadding(new Insets(12)); + form.setStyle("-fx-background-color: #252535;"); + + idField = field("z. B. wald"); + nameKeyLabel = new Label("Name-Key: —"); + nameKeyLabel.setStyle("-fx-text-fill: #aaaaaa; -fx-font-style: italic; -fx-font-size: 11;"); + idField.textProperty().addListener((obs, o, n) -> { + String key = n == null || n.isBlank() ? "" : PREFIX + n.trim() + ".name"; + nameKeyLabel.setText("Name-Key: " + (key.isBlank() ? "—" : key)); + scheduleSave(); + }); + idField.focusedProperty().addListener((obs, was, is) -> { + if (!is) { savePause.stop(); persistCurrent(); } + }); + + for (int i = 0; i < 3; i++) { + trackLabels[i] = new Label("(keine)"); + trackLabels[i].setStyle("-fx-text-fill: #aaaaaa; -fx-font-size: 11;"); + trackLabels[i].setWrapText(true); + } + + form.getChildren().addAll( + sectionTitle("Kennung"), + new Separator(), + row("ID:", idField), + nameKeyLabel, + sectionTitle("Musik-Tracks"), + new Separator(), + trackRow(0), + trackRow(1), + trackRow(2) + ); + return form; + } + + private VBox trackRow(int idx) { + Button btn = new Button("🎵 Wählen…"); + btn.setStyle("-fx-background-color: #3a3a5a; -fx-text-fill: #ddd; -fx-font-size: 11;"); + btn.setOnAction(e -> { + Path assetRoot = ProjectRoot.resolve("blight-assets", "src", "main", "resources"); + new SoundChooser(assetRoot, SoundChooser.Mode.MUSIC) + .showAndWait() + .ifPresent(rel -> { + tracks[idx] = rel; + trackLabels[idx].setText(rel.isEmpty() ? "(keine)" : rel); + scheduleSave(); + }); + }); + + Button clearBtn = new Button("✕"); + clearBtn.setStyle("-fx-background-color: #5a2a2a; -fx-text-fill: #ddd; -fx-font-size: 10;"); + clearBtn.setOnAction(e -> { + tracks[idx] = ""; + trackLabels[idx].setText("(keine)"); + scheduleSave(); + }); + + Label header = new Label(TRACK_NAMES[idx] + ":"); + header.setStyle("-fx-text-fill: #88aacc; -fx-font-size: 11;"); + + HBox btns = new HBox(4, btn, clearBtn); + return new VBox(2, header, trackLabels[idx], btns); + } + + // ── Form load / save ────────────────────────────────────────────────────── + + private void onSelected(AreaDefinition old, AreaDefinition nw) { + if (reloading) return; + if (old != null) { saveFormToArea(old); persist(); } + current = nw; + deleteBtn.setDisable(nw == null); + if (nw != null) { formContainer.setDisable(false); loadForm(nw); } + else { formContainer.setDisable(true); clearForm(); } + } + + private void loadForm(AreaDefinition d) { + String shortId = d.id().startsWith(PREFIX) ? d.id().substring(PREFIX.length()) : d.id(); + idField.setText(shortId); + nameKeyLabel.setText("Name-Key: " + (shortId.isBlank() ? "—" : PREFIX + shortId + ".name")); + tracks[0] = d.dayTrack(); + tracks[1] = d.nightTrack(); + tracks[2] = d.combatTrack(); + for (int i = 0; i < 3; i++) { + trackLabels[i].setText(tracks[i].isEmpty() ? "(keine)" : tracks[i]); + } + } + + private AreaDefinition formToArea() { + String shortId = idField.getText().trim(); + String fullId = shortId.isBlank() ? "" : PREFIX + shortId; + return new AreaDefinition(fullId, tracks[0], tracks[1], tracks[2]); + } + + private void saveFormToArea(AreaDefinition old) { + int idx = areas.indexOf(old); + if (idx < 0) return; + areas.set(idx, formToArea()); + current = areas.get(idx); + } + + private void clearForm() { + idField.clear(); + if (nameKeyLabel != null) nameKeyLabel.setText("Name-Key: —"); + for (int i = 0; i < 3; i++) { + tracks[i] = ""; + if (trackLabels[i] != null) trackLabels[i].setText("(keine)"); + } + } + + // ── List operations ─────────────────────────────────────────────────────── + + private void createArea() { + AreaDefinition d = new AreaDefinition(PREFIX + "neu_" + System.currentTimeMillis(), "", "", ""); + areas.add(d); + listView.getSelectionModel().select(d); + } + + private void deleteSelected() { + if (current == null) return; + areas.remove(current); + current = null; + clearForm(); + formContainer.setDisable(true); + deleteBtn.setDisable(true); + persist(); + reload(); + } + + private void persist() { + try { AreaDefinitionIO.save(areas); } + catch (IOException e) { + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + } + } + + public void reload() { + try { areas.setAll(AreaDefinitionIO.load()); } + catch (IOException e) { areas.clear(); } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static Label sectionTitle(String text) { + Label l = new Label(text); + l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #cc8866;"); + return l; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(50); + lbl.setStyle("-fx-text-fill: #aaa;"); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static TextField field(String prompt) { + TextField tf = new TextField(); + tf.setPromptText(prompt); + return tf; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/ConditionDialog.java b/blight-editor/src/main/java/de/blight/editor/ui/ConditionDialog.java new file mode 100644 index 0000000..6c406fb --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/ConditionDialog.java @@ -0,0 +1,222 @@ +package de.blight.editor.ui; + +import de.blight.common.model.trigger.*; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.stage.Modality; + +import java.util.UUID; + +public class ConditionDialog extends Dialog { + + private static final String TYPE_CHAPTER = "Kapitel erreicht"; + private static final String TYPE_FIRST_TIME = "Erstes Mal"; + private static final String TYPE_QUEST_COMPLETED = "Quest abgeschlossen"; + private static final String TYPE_QUEST_ACCEPTED = "Quest angenommen"; + private static final String TYPE_QUEST_REJECTED = "Quest abgelehnt"; + private static final String TYPE_GAME_VARIABLE = "Spielvariable gesetzt"; + private static final String TYPE_FACTION_MEMBER = "Fraktionsmitglied"; + + private final ComboBox typeCombo = new ComboBox<>(); + private final VBox dynamicArea = new VBox(6); + + private Spinner chapterSpinner; + private TextField zoneIdField; + private TextField questIdField; + private TextField varKeyField, varValueField; + private TextField fractionIdField; + + public ConditionDialog() { + this(null); + } + + public ConditionDialog(Condition existing) { + setTitle(existing == null ? "Bedingung hinzufügen" : "Bedingung bearbeiten"); + initModality(Modality.APPLICATION_MODAL); + initOwner(Dialogs.primaryWindow()); + setResizable(true); + + typeCombo.getItems().addAll(TYPE_CHAPTER, TYPE_FIRST_TIME, + TYPE_QUEST_COMPLETED, TYPE_QUEST_ACCEPTED, TYPE_QUEST_REJECTED, + TYPE_GAME_VARIABLE, TYPE_FACTION_MEMBER); + typeCombo.setMaxWidth(Double.MAX_VALUE); + typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue())); + + VBox content = new VBox(10); + content.setPadding(new Insets(16)); + content.setPrefWidth(380); + content.getChildren().addAll(row("Bedingungstyp:", typeCombo), new Separator(), dynamicArea); + + getDialogPane().setContent(content); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK); + okBtn.setDisable(true); + typeCombo.valueProperty().addListener((obs, o, n) -> okBtn.setDisable(n == null)); + + setResultConverter(bt -> bt == ButtonType.OK ? buildCondition() : null); + + if (existing != null) { + preload(existing); + } else { + typeCombo.setValue(TYPE_CHAPTER); + } + } + + // ── Felder aufbauen ─────────────────────────────────────────────────────── + + private void rebuildDynamic(String type) { + dynamicArea.getChildren().clear(); + if (type == null) return; + switch (type) { + case TYPE_CHAPTER -> buildChapterFields(); + case TYPE_FIRST_TIME -> buildFirstTimeFields(); + case TYPE_QUEST_COMPLETED, + TYPE_QUEST_ACCEPTED, + TYPE_QUEST_REJECTED -> buildQuestFields(); + case TYPE_GAME_VARIABLE -> buildVarFields(); + case TYPE_FACTION_MEMBER -> buildFractionFields(); + } + } + + private void buildChapterFields() { + chapterSpinner = new Spinner<>(0, 99, 0); + chapterSpinner.setEditable(true); + chapterSpinner.setPrefWidth(80); + dynamicArea.getChildren().add(row("Mindest-Kapitel:", chapterSpinner)); + } + + private void buildFirstTimeFields() { + zoneIdField = field("Zone-ID (nameId der Zone)"); + dynamicArea.getChildren().add(row("Zone-ID:", zoneIdField)); + } + + private void buildQuestFields() { + questIdField = field("Quest-ID (z. B. q_main_001)"); + dynamicArea.getChildren().add(row("Quest-ID:", questIdField)); + } + + private void buildVarFields() { + varKeyField = field("Variablenname"); + varValueField = field("Erwarteter Wert (leer = nur Existenz prüfen)"); + dynamicArea.getChildren().addAll( + row("Schlüssel:", varKeyField), + row("Wert:", varValueField)); + } + + private void buildFractionFields() { + fractionIdField = field("UUID der Fraktion"); + dynamicArea.getChildren().add(row("Fraktions-UUID:", fractionIdField)); + } + + // ── Bedingung bauen ─────────────────────────────────────────────────────── + + private Condition buildCondition() { + String type = typeCombo.getValue(); + if (type == null) return null; + return switch (type) { + case TYPE_CHAPTER -> { + ChapterCondition c = new ChapterCondition(); + if (chapterSpinner != null) c.setMinChapter(chapterSpinner.getValue()); + yield c; + } + case TYPE_FIRST_TIME -> { + FirstTimeCondition c = new FirstTimeCondition(); + if (zoneIdField != null) c.setZoneId(zoneIdField.getText().trim()); + yield c; + } + case TYPE_QUEST_COMPLETED -> { + QuestCompletedCondition c = new QuestCompletedCondition(); + if (questIdField != null) c.setQuestId(questIdField.getText().trim()); + yield c; + } + case TYPE_QUEST_ACCEPTED -> { + QuestAcceptedCondition c = new QuestAcceptedCondition(); + if (questIdField != null) c.setQuestId(questIdField.getText().trim()); + yield c; + } + case TYPE_QUEST_REJECTED -> { + QuestRejectedCondition c = new QuestRejectedCondition(); + if (questIdField != null) c.setQuestId(questIdField.getText().trim()); + yield c; + } + case TYPE_GAME_VARIABLE -> { + GameVariableCondition c = new GameVariableCondition(); + if (varKeyField != null) c.setKey(varKeyField.getText().trim()); + if (varValueField != null) c.setValue(varValueField.getText().trim()); + yield c; + } + case TYPE_FACTION_MEMBER -> { + FactionMemberCondition c = new FactionMemberCondition(); + if (fractionIdField != null && !fractionIdField.getText().isBlank()) { + try { c.setFractionId(UUID.fromString(fractionIdField.getText().trim())); } + catch (IllegalArgumentException ignored) {} + } + yield c; + } + default -> null; + }; + } + + // ── Vorladen ────────────────────────────────────────────────────────────── + + private void preload(Condition c) { + if (c instanceof ChapterCondition cc) { + typeCombo.setValue(TYPE_CHAPTER); + if (chapterSpinner != null) chapterSpinner.getValueFactory().setValue(cc.getMinChapter()); + } else if (c instanceof FirstTimeCondition ft) { + typeCombo.setValue(TYPE_FIRST_TIME); + if (zoneIdField != null && ft.getZoneId() != null) zoneIdField.setText(ft.getZoneId()); + } else if (c instanceof QuestCompletedCondition qc) { + typeCombo.setValue(TYPE_QUEST_COMPLETED); + if (questIdField != null && qc.getQuestId() != null) questIdField.setText(qc.getQuestId()); + } else if (c instanceof QuestAcceptedCondition qa) { + typeCombo.setValue(TYPE_QUEST_ACCEPTED); + if (questIdField != null && qa.getQuestId() != null) questIdField.setText(qa.getQuestId()); + } else if (c instanceof QuestRejectedCondition qr) { + typeCombo.setValue(TYPE_QUEST_REJECTED); + if (questIdField != null && qr.getQuestId() != null) questIdField.setText(qr.getQuestId()); + } else if (c instanceof GameVariableCondition gv) { + typeCombo.setValue(TYPE_GAME_VARIABLE); + if (varKeyField != null && gv.getKey() != null) varKeyField.setText(gv.getKey()); + if (varValueField != null && gv.getValue() != null) varValueField.setText(gv.getValue()); + } else if (c instanceof FactionMemberCondition fm) { + typeCombo.setValue(TYPE_FACTION_MEMBER); + if (fractionIdField != null && fm.getFractionId() != null) + fractionIdField.setText(fm.getFractionId().toString()); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + static String describe(Condition c) { + if (c instanceof ChapterCondition cc) return "Kapitel ≥ " + cc.getMinChapter(); + if (c instanceof FirstTimeCondition ft) return "Erstes Mal: " + nullSafe(ft.getZoneId()); + if (c instanceof QuestCompletedCondition q) return "Quest abgeschlossen: " + nullSafe(q.getQuestId()); + if (c instanceof QuestAcceptedCondition q) return "Quest angenommen: " + nullSafe(q.getQuestId()); + if (c instanceof QuestRejectedCondition q) return "Quest abgelehnt: " + nullSafe(q.getQuestId()); + if (c instanceof GameVariableCondition gv) return "Var " + nullSafe(gv.getKey()) + " = " + nullSafe(gv.getValue()); + if (c instanceof FactionMemberCondition fm) return "Fraktion: " + (fm.getFractionId() != null ? fm.getFractionId().toString().substring(0, 8) + "…" : "?"); + return c.getClass().getSimpleName(); + } + + private static String nullSafe(String s) { return s != null && !s.isBlank() ? s : "?"; } + + private static TextField field(String prompt) { + TextField tf = new TextField(); + tf.setPromptText(prompt); + return tf; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(130); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java index 14e6159..d69a2ce 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java @@ -1,11 +1,13 @@ package de.blight.editor.ui; import de.blight.common.model.*; +import javafx.animation.PauseTransition; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.*; +import javafx.util.Duration; import java.io.IOException; import java.nio.file.Path; @@ -35,6 +37,12 @@ public class CraftingTableEditorView extends BorderPane { private TextField objectPathField; private VBox formContainer; + private final PauseTransition savePause = new PauseTransition(Duration.millis(600)); + + { + savePause.setOnFinished(e -> persistCurrent()); + } + public CraftingTableEditorView(Path tableDir) { this.tableDir = tableDir; setStyle("-fx-background-color: #1e1e2e;"); @@ -108,14 +116,11 @@ public class CraftingTableEditorView extends BorderPane { nameIdField = new TextField(); nameIdField.setPromptText("Text-Referenz ID (z. B. ui.crafting.alchemy_table)"); + nameIdField.textProperty().addListener((obs, o, n) -> scheduleSave()); objectPathField = new TextField(); objectPathField.setPromptText("Asset-Pfad zum 3D-Objekt (z. B. Models/crafting/alchemy_table.j3o)"); - - Button saveBtn = new Button("Crafting Table speichern"); - saveBtn.setMaxWidth(Double.MAX_VALUE); - saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); - saveBtn.setOnAction(e -> saveCurrentTable()); + objectPathField.textProperty().addListener((obs, o, n) -> scheduleSave()); form.getChildren().addAll( formTypeLabel, @@ -124,13 +129,22 @@ public class CraftingTableEditorView extends BorderPane { row("Name-ID:", nameIdField), new Separator(), sectionTitle("3D-Objekt"), - row("Pfad:", objectPathField), - new Separator(), - saveBtn + row("Pfad:", objectPathField) ); return form; } + // ── Auto-save ───────────────────────────────────────────────────────────── + + private void scheduleSave() { + savePause.playFromStart(); + } + + private void persistCurrent() { + if (currentType == null) { return; } + saveCurrentTable(); + } + // ── Form load / save ────────────────────────────────────────────────────── private void onTypeSelected(CraftingTable.CraftingTableType old, CraftingTable.CraftingTableType nw) { @@ -174,7 +188,7 @@ public class CraftingTableEditorView extends BorderPane { try { CraftingTableIO.save(t, tableDir); } catch (IOException e) { - new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); return; } reloadMap(); @@ -187,7 +201,7 @@ public class CraftingTableEditorView extends BorderPane { try { CraftingTableIO.delete(currentType, tableDir); } catch (IOException e) { - new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); return; } reloadMap(); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java index bed2a20..24f6f82 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java @@ -696,11 +696,11 @@ public class DialogEditorView extends BorderPane { } String id = result.get().trim(); if (id.isBlank()) { - new Alert(Alert.AlertType.WARNING, "ID darf nicht leer sein.", ButtonType.OK).showAndWait(); + Dialogs.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(); + Dialogs.alert(Alert.AlertType.WARNING, "ID '" + id + "' bereits vorhanden.", ButtonType.OK).showAndWait(); return; } DialogOption opt = new DialogOption(); @@ -749,6 +749,7 @@ public class DialogEditorView extends BorderPane { Dialog dlg = new Dialog<>(); dlg.setTitle("Option auswählen"); dlg.initModality(Modality.APPLICATION_MODAL); + dlg.initOwner(Dialogs.primaryWindow()); ListView chooser = new ListView<>(); chooser.setCellFactory(lv -> new ListCell<>() { @@ -794,6 +795,7 @@ public class DialogEditorView extends BorderPane { Dialog dlg = new Dialog<>(); dlg.setTitle("Quest auswählen"); dlg.initModality(Modality.APPLICATION_MODAL); + dlg.initOwner(Dialogs.primaryWindow()); ListView chooser = new ListView<>(); chooser.setCellFactory(lv -> new ListCell<>() { @@ -1119,6 +1121,7 @@ public class DialogEditorView extends BorderPane { private static Trigger buildTriggerDialog(String type) { Dialog dlg = new Dialog<>(); + dlg.initOwner(Dialogs.primaryWindow()); dlg.setTitle("Trigger konfigurieren: " + type); dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/Dialogs.java b/blight-editor/src/main/java/de/blight/editor/ui/Dialogs.java new file mode 100644 index 0000000..ff30a2e --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/Dialogs.java @@ -0,0 +1,21 @@ +package de.blight.editor.ui; + +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.stage.Window; + +public final class Dialogs { + private Dialogs() {} + + public static Window primaryWindow() { + return Window.getWindows().stream() + .filter(Window::isShowing) + .findFirst().orElse(null); + } + + public static Alert alert(Alert.AlertType type, String msg, ButtonType... btns) { + Alert a = new Alert(type, msg, btns); + a.initOwner(primaryWindow()); + return a; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/FractionEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/FractionEditorView.java index a838120..cd5263d 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/FractionEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/FractionEditorView.java @@ -1,6 +1,7 @@ package de.blight.editor.ui; import de.blight.common.model.*; +import javafx.animation.PauseTransition; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; @@ -9,6 +10,7 @@ import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.*; +import javafx.util.Duration; import java.io.IOException; import java.nio.file.Path; @@ -41,6 +43,12 @@ public class FractionEditorView extends BorderPane { private TextField rank3Field; private VBox formContainer; + private final PauseTransition savePause = new PauseTransition(Duration.millis(600)); + + { + savePause.setOnFinished(e -> persistCurrent()); + } + public FractionEditorView(Path fractionDir) { this.fractionDir = fractionDir; this.sortedFractions = new SortedList<>(fractions, FractionIO.SORT_ORDER); @@ -120,16 +128,17 @@ public class FractionEditorView extends BorderPane { idLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #666; -fx-font-family: monospace;"); nameField = field("z. B. faction.guards"); + nameField.textProperty().addListener((obs, o, n) -> scheduleSave()); maleMemberField = field("z. B. faction.guards.member.male"); + maleMemberField.textProperty().addListener((obs, o, n) -> scheduleSave()); femaleMemberField = field("z. B. faction.guards.member.female"); + femaleMemberField.textProperty().addListener((obs, o, n) -> scheduleSave()); rank1Field = field("z. B. faction.guards.rank1"); + rank1Field.textProperty().addListener((obs, o, n) -> scheduleSave()); rank2Field = field("z. B. faction.guards.rank2"); + rank2Field.textProperty().addListener((obs, o, n) -> scheduleSave()); rank3Field = field("z. B. faction.guards.rank3"); - - Button saveBtn = new Button("Fraktion speichern"); - saveBtn.setMaxWidth(Double.MAX_VALUE); - saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); - saveBtn.setOnAction(e -> saveCurrentFraction()); + rank3Field.textProperty().addListener((obs, o, n) -> scheduleSave()); form.getChildren().addAll( sectionTitle("Kennung"), @@ -144,17 +153,37 @@ public class FractionEditorView extends BorderPane { new Separator(), row("Rang 1:", rank1Field), row("Rang 2:", rank2Field), - row("Rang 3:", rank3Field), - new Separator(), - saveBtn + row("Rang 3:", rank3Field) ); return form; } + // ── Auto-save ───────────────────────────────────────────────────────────── + + private void scheduleSave() { + savePause.playFromStart(); + } + + private void persistCurrent() { + if (current == null) { return; } + saveFormToFraction(current); + if (current.getFractionId() == null) { return; } + try { + FractionIO.save(current, fractionDir); + } catch (IOException e) { + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + } + } + // ── Form load / save ────────────────────────────────────────────────────── private void onFractionSelected(Fraction old, Fraction nw) { - if (old != null) saveFormToFraction(old); + if (old != null) { + saveFormToFraction(old); + if (old.getFractionId() != null) { + try { FractionIO.save(old, fractionDir); } catch (IOException e) { /* ignore on navigation */ } + } + } current = nw; deleteBtn.setDisable(nw == null); if (nw != null) { @@ -217,28 +246,6 @@ public class FractionEditorView extends BorderPane { reload(); } - private void saveCurrentFraction() { - if (current == null) return; - saveFormToFraction(current); - if (current.getFractionId() == null) { - new Alert(Alert.AlertType.ERROR, - "Fraktion hat keine UUID – bitte neu erstellen.", ButtonType.OK).showAndWait(); - return; - } - try { - FractionIO.save(current, fractionDir); - } catch (IOException e) { - new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); - return; - } - reload(); - final UUID fid = current.getFractionId(); - fractions.stream() - .filter(f -> fid.equals(f.getFractionId())) - .findFirst() - .ifPresent(listView.getSelectionModel()::select); - } - public void reload() { fractions.setAll(FractionIO.loadAll(fractionDir)); } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/ItemEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/ItemEditorView.java index 946eee2..e72c71b 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/ItemEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/ItemEditorView.java @@ -9,6 +9,7 @@ import de.blight.common.model.ItemIO; import de.blight.common.model.ItemSubCategory; import de.blight.common.model.ObjectReference; import de.blight.common.model.TextReference; +import javafx.animation.PauseTransition; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; @@ -18,6 +19,7 @@ import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.*; import javafx.scene.paint.Color; +import javafx.util.Duration; import java.io.IOException; import java.nio.file.Path; @@ -51,6 +53,12 @@ public class ItemEditorView extends BorderPane { private VBox consumablesSection; private VBox effectsRows; + private final PauseTransition savePause = new PauseTransition(Duration.millis(600)); + + { + savePause.setOnFinished(e -> persistCurrent()); + } + public ItemEditorView(Path itemDir) { this.itemDir = itemDir; this.assetRoot = itemDir.getParent(); // items/ ist direkt unter assetRoot @@ -188,6 +196,7 @@ public class ItemEditorView extends BorderPane { if (wasFocused && !isFocused) onIdCommitted(); }); idField.setOnAction(e -> onIdCommitted()); + idField.textProperty().addListener((obs, o, n) -> scheduleSave()); catCombo = new ComboBox<>(); catCombo.getItems().addAll(ItemCategory.values()); @@ -237,15 +246,20 @@ public class ItemEditorView extends BorderPane { } else { subCatCombo.setValue(null); } + scheduleSave(); }); + subCatCombo.valueProperty().addListener((obs, o, n) -> scheduleSave()); nameField = new TextField(); nameField.setPromptText("TextReference-Schlüssel"); + nameField.textProperty().addListener((obs, o, n) -> scheduleSave()); descField = new TextField(); descField.setPromptText("TextReference-Schlüssel"); + descField.textProperty().addListener((obs, o, n) -> scheduleSave()); goldSpinner = new Spinner<>(0, 999999, 0); goldSpinner.setEditable(true); goldSpinner.setMaxWidth(Double.MAX_VALUE); + goldSpinner.valueProperty().addListener((obs, o, n) -> scheduleSave()); modelRefField = new TextField(); modelRefField.setPromptText("Modell-Pfad (z.B. Models/Items/sword.j3o)"); @@ -287,13 +301,9 @@ public class ItemEditorView extends BorderPane { consumableCheck.selectedProperty().addListener((obs, wasSelected, isSelected) -> { consumablesSection.setVisible(isSelected); consumablesSection.setManaged(isSelected); + scheduleSave(); }); - Button saveBtn = new Button("Item speichern"); - saveBtn.setMaxWidth(Double.MAX_VALUE); - saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); - saveBtn.setOnAction(e -> saveCurrentItem()); - form.getChildren().addAll( sectionTitle("Item"), new Separator(), @@ -310,9 +320,7 @@ public class ItemEditorView extends BorderPane { row("Modell:", modelFullRow), new Separator(), consumableCheck, - consumablesSection, - new Separator(), - saveBtn + consumablesSection ); return form; } @@ -331,10 +339,32 @@ public class ItemEditorView extends BorderPane { if (descField.getText().isBlank()) descField.setText(id + ".description"); } + // ── Auto-save ───────────────────────────────────────────────────────────── + + private void scheduleSave() { + savePause.playFromStart(); + } + + private void persistCurrent() { + if (current == null) { return; } + saveFormToItem(current); + if (current.getItemId() == null || current.getItemId().isBlank()) { return; } + try { + ItemIO.save(current, itemDir); + } catch (IOException e) { + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + } + } + // ── Form load / save ────────────────────────────────────────────────────── private void onItemSelected(Item old, Item nw) { - if (old != null) saveFormToItem(old); + if (old != null) { + saveFormToItem(old); + if (old.getItemId() != null && !old.getItemId().isBlank()) { + try { ItemIO.save(old, itemDir); } catch (IOException e) { /* ignore on navigation */ } + } + } current = nw; deleteBtn.setDisable(nw == null); if (nw != null) { @@ -424,24 +454,6 @@ public class ItemEditorView extends BorderPane { reload(); } - private void saveCurrentItem() { - if (current == null) return; - saveFormToItem(current); - if (current.getItemId() == null || current.getItemId().isBlank()) { - new Alert(Alert.AlertType.ERROR, "Item-ID darf nicht leer sein.", ButtonType.OK).showAndWait(); - return; - } - try { - ItemIO.save(current, itemDir); - } catch (IOException e) { - new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); - return; - } - String savedId = current.getItemId(); - reload(); - selectItem(savedId); - } - // ── Effect rows ─────────────────────────────────────────────────────────── private void addEffectRow(CharacterStat stat, int value) { diff --git a/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java index 21ce29e..3ebd3ce 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java @@ -6,6 +6,7 @@ import de.blight.common.model.TextBundle; import de.blight.common.model.TextBundleIO; import de.blight.common.model.TextKeyStore; import de.blight.common.model.TextRegistry; +import javafx.animation.PauseTransition; import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -16,6 +17,7 @@ import javafx.scene.control.*; import javafx.scene.control.cell.TextFieldTableCell; import javafx.scene.layout.*; import javafx.stage.FileChooser; +import javafx.util.Duration; import java.io.File; import java.io.IOException; @@ -39,6 +41,12 @@ public class LocalizationEditorView extends BorderPane { private final TableView audioTable = new TableView<>(audioData); private AudioBundle currentAudioBundle = null; + private final PauseTransition savePause = new PauseTransition(Duration.millis(600)); + + { + savePause.setOnFinished(e -> saveCurrent()); + } + public LocalizationEditorView(Path locDir) { this.locDir = locDir; setStyle("-fx-background-color: #1e1e2e;"); @@ -58,13 +66,7 @@ public class LocalizationEditorView extends BorderPane { Button delLangBtn = new Button("- Sprache"); delLangBtn.setOnAction(e -> deleteLanguage()); - 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), - saveBtn); + HBox toolbar = new HBox(8, langLbl, langCombo, addLangBtn, delLangBtn); 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;"); @@ -106,13 +108,13 @@ public class LocalizationEditorView extends BorderPane { TableColumn keyCol = new TableColumn<>("Schluessel"); keyCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[0])); keyCol.setCellFactory(TextFieldTableCell.forTableColumn()); - keyCol.setOnEditCommit(e -> { e.getRowValue()[0] = e.getNewValue().trim(); table.refresh(); }); + keyCol.setOnEditCommit(e -> { e.getRowValue()[0] = e.getNewValue().trim(); table.refresh(); scheduleSave(); }); keyCol.setPrefWidth(280); TableColumn valCol = new TableColumn<>("Text"); valCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[1])); valCol.setCellFactory(TextFieldTableCell.forTableColumn()); - valCol.setOnEditCommit(e -> { e.getRowValue()[1] = e.getNewValue(); table.refresh(); }); + valCol.setOnEditCommit(e -> { e.getRowValue()[1] = e.getNewValue(); table.refresh(); scheduleSave(); }); table.getColumns().addAll(List.of(keyCol, valCol)); VBox.setVgrow(table, Priority.ALWAYS); @@ -150,13 +152,13 @@ public class LocalizationEditorView extends BorderPane { TableColumn keyCol = new TableColumn<>("Schluessel"); keyCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[0])); keyCol.setCellFactory(TextFieldTableCell.forTableColumn()); - keyCol.setOnEditCommit(e -> { e.getRowValue()[0] = e.getNewValue().trim(); audioTable.refresh(); }); + keyCol.setOnEditCommit(e -> { e.getRowValue()[0] = e.getNewValue().trim(); audioTable.refresh(); scheduleSave(); }); keyCol.setPrefWidth(220); TableColumn pathCol = new TableColumn<>("Datei"); pathCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[1])); pathCol.setCellFactory(TextFieldTableCell.forTableColumn()); - pathCol.setOnEditCommit(e -> { e.getRowValue()[1] = e.getNewValue(); audioTable.refresh(); }); + pathCol.setOnEditCommit(e -> { e.getRowValue()[1] = e.getNewValue(); audioTable.refresh(); scheduleSave(); }); TableColumn browseCol = new TableColumn<>(""); browseCol.setPrefWidth(36); @@ -179,6 +181,7 @@ public class LocalizationEditorView extends BorderPane { if (file != null) { audioData.get(idx)[1] = file.getAbsolutePath(); audioTable.refresh(); + scheduleSave(); } }); } @@ -234,6 +237,7 @@ public class LocalizationEditorView extends BorderPane { private void addLanguage() { TextInputDialog dlg = new TextInputDialog(); + dlg.initOwner(Dialogs.primaryWindow()); dlg.setTitle("Sprache hinzufuegen"); dlg.setHeaderText("Sprach-Code (z. B. de, en, fr):"); dlg.showAndWait().ifPresent(lang -> { @@ -245,7 +249,7 @@ public class LocalizationEditorView extends BorderPane { refreshLangList(); langCombo.setValue(lang); } catch (IOException e) { - new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); } }); } @@ -253,7 +257,7 @@ public class LocalizationEditorView extends BorderPane { private void deleteLanguage() { String lang = langCombo.getValue(); if (lang == null) return; - Alert confirm = new Alert(Alert.AlertType.CONFIRMATION, + Alert confirm = Dialogs.alert(Alert.AlertType.CONFIRMATION, "Sprache '" + lang + "' wirklich loeschen?", ButtonType.YES, ButtonType.NO); confirm.showAndWait().ifPresent(bt -> { if (bt == ButtonType.YES) { @@ -266,7 +270,7 @@ public class LocalizationEditorView extends BorderPane { audioData.clear(); refreshLangList(); } catch (IOException e) { - new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); } } }); @@ -282,6 +286,7 @@ public class LocalizationEditorView extends BorderPane { if (available.isEmpty()) { TextInputDialog dlg = new TextInputDialog(); + dlg.initOwner(Dialogs.primaryWindow()); dlg.setTitle("Schluessel hinzufuegen"); dlg.setHeaderText("Alle bekannten Schluessel bereits vorhanden.\nNeuen Schluessel eingeben:"); dlg.showAndWait().map(String::trim).filter(s -> !s.isBlank()).ifPresent(this::insertKey); @@ -289,6 +294,7 @@ public class LocalizationEditorView extends BorderPane { } Dialog dlg = new Dialog<>(); + dlg.initOwner(Dialogs.primaryWindow()); dlg.setTitle("Schluessel waehlen"); dlg.setHeaderText("Noch nicht uebersetzte Schluessel:"); dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); @@ -315,36 +321,45 @@ public class LocalizationEditorView extends BorderPane { tableData.add(new String[]{key, ""}); table.scrollTo(tableData.size() - 1); table.getSelectionModel().select(tableData.size() - 1); + scheduleSave(); } private void deleteKey() { String[] sel = table.getSelectionModel().getSelectedItem(); - if (sel != null) tableData.remove(sel); + if (sel != null) { tableData.remove(sel); scheduleSave(); } } // Audio-Eintrags-Verwaltung private void addAudioKey() { TextInputDialog dlg = new TextInputDialog(); + dlg.initOwner(Dialogs.primaryWindow()); dlg.setTitle("Audio-Schluessel hinzufuegen"); dlg.setHeaderText("Schluessel eingeben (muss dem AudioReference-Key im Dialog entsprechen):"); dlg.showAndWait().map(String::trim).filter(s -> !s.isBlank()).ifPresent(key -> { audioData.add(new String[]{key, ""}); audioTable.scrollTo(audioData.size() - 1); audioTable.getSelectionModel().select(audioData.size() - 1); + scheduleSave(); }); } private void deleteAudioKey() { String[] sel = audioTable.getSelectionModel().getSelectedItem(); - if (sel != null) audioData.remove(sel); + if (sel != null) { audioData.remove(sel); scheduleSave(); } + } + + // Auto-save + + private void scheduleSave() { + savePause.playFromStart(); } // Speichern private void saveCurrent() { if (currentBundle == null) { - new Alert(Alert.AlertType.WARNING, "Keine Sprache ausgewaehlt.", ButtonType.OK).showAndWait(); + Dialogs.alert(Alert.AlertType.WARNING, "Keine Sprache ausgewaehlt.", ButtonType.OK).showAndWait(); return; } Map textEntries = new LinkedHashMap<>(); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java index 2447746..a61e912 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java @@ -3,6 +3,7 @@ package de.blight.editor.ui; import de.blight.common.LocationIO; import de.blight.common.model.Location; import de.blight.common.model.TextReference; +import javafx.animation.PauseTransition; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; @@ -10,32 +11,29 @@ import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.*; +import javafx.util.Duration; import java.io.IOException; import java.util.List; -/** - * Locations-Verwaltung: Liste links, Formular rechts. - * Alle Locations werden gemeinsam in einer Datei gespeichert (LocationIO). - */ public class LocationEditorView extends BorderPane { - private final ObservableList locations = FXCollections.observableArrayList(); + private static final String PREFIX = "location."; - // ── List ────────────────────────────────────────────────────────────────── + private final ObservableList locations = FXCollections.observableArrayList(); + private final PauseTransition savePause = new PauseTransition(Duration.millis(600)); private ListView listView; private Button deleteBtn; - private Location current = null; + private Location current = null; + private boolean reloading = false; - // ── Form fields ─────────────────────────────────────────────────────────── - - private TextField nameIdField; - private TextField centerXField; - private TextField centerZField; - private TextField radiusField; + private TextField idField; + private Label nameKeyLabel; private TriggerListEditor triggerEditor; - private VBox formContainer; + private VBox formContainer; + + { savePause.setOnFinished(e -> persistCurrent()); } public LocationEditorView() { setStyle("-fx-background-color: #1e1e2e;"); @@ -46,6 +44,18 @@ public class LocationEditorView extends BorderPane { setCenter(split); } + // ── Auto-save ───────────────────────────────────────────────────────────── + + private void scheduleSave() { savePause.playFromStart(); } + + private void persistCurrent() { + if (current == null || reloading) return; + saveFormToLocation(current); + if (current.getId() == null || current.getId().isBlank()) return; + persist(); + listView.refresh(); + } + // ── List panel ──────────────────────────────────────────────────────────── private VBox buildListPanel() { @@ -55,7 +65,9 @@ public class LocationEditorView extends BorderPane { @Override protected void updateItem(Location loc, boolean empty) { super.updateItem(loc, empty); if (empty || loc == null) { setText(null); setStyle(""); return; } - setText(loc.getId().isBlank() ? "—" : loc.getId()); + String shortId = loc.getId().startsWith(PREFIX) + ? loc.getId().substring(PREFIX.length()) : loc.getId(); + setText(shortId.isBlank() ? "—" : shortId); setStyle("-fx-text-fill: #dddddd;" + " -fx-border-color: transparent transparent transparent #66aacc;" + " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;"); @@ -108,30 +120,28 @@ public class LocationEditorView extends BorderPane { form.setPadding(new Insets(12)); form.setStyle("-fx-background-color: #252535;"); - nameIdField = field("z. B. location.village"); - centerXField = field("X-Koordinate"); - centerZField = field("Z-Koordinate"); - radiusField = field("Radius in Meter"); + idField = field("z. B. village"); + nameKeyLabel = new Label("Name-Key: —"); + nameKeyLabel.setStyle("-fx-text-fill: #aaaaaa; -fx-font-style: italic; -fx-font-size: 11;"); + idField.textProperty().addListener((obs, o, n) -> { + String key = n == null || n.isBlank() ? "" : PREFIX + n.trim() + ".name"; + nameKeyLabel.setText("Name-Key: " + (key.isBlank() ? "—" : key)); + scheduleSave(); + }); + idField.focusedProperty().addListener((obs, was, is) -> { + if (!is) { savePause.stop(); persistCurrent(); } + }); triggerEditor = new TriggerListEditor(List.of(), () -> {}); - Button saveBtn = new Button("Location speichern"); - saveBtn.setMaxWidth(Double.MAX_VALUE); - saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); - saveBtn.setOnAction(e -> saveCurrent()); - form.getChildren().addAll( - sectionTitle("Kennung & Position"), + sectionTitle("Kennung"), new Separator(), - row("Name-ID:", nameIdField), - row("Mitte X:", centerXField), - row("Mitte Z:", centerZField), - row("Radius:", radiusField), + row("ID:", idField), + nameKeyLabel, sectionTitle("Trigger"), new Separator(), - triggerEditor, - new Separator(), - saveBtn + triggerEditor ); return form; } @@ -139,7 +149,8 @@ public class LocationEditorView extends BorderPane { // ── Form load / save ────────────────────────────────────────────────────── private void onSelected(Location old, Location nw) { - if (old != null) saveFormToLocation(old); + if (reloading) return; + if (old != null) { saveFormToLocation(old); persist(); } current = nw; deleteBtn.setDisable(nw == null); if (nw != null) { formContainer.setDisable(false); loadForm(nw); } @@ -147,38 +158,34 @@ public class LocationEditorView extends BorderPane { } private void loadForm(Location loc) { - nameIdField.setText(loc.getId()); - centerXField.setText(String.valueOf(loc.getCenterX())); - centerZField.setText(String.valueOf(loc.getCenterZ())); - radiusField.setText(String.valueOf(loc.getRadius())); + String fullId = loc.getId(); + String shortId = fullId.startsWith(PREFIX) ? fullId.substring(PREFIX.length()) : fullId; + idField.setText(shortId); + nameKeyLabel.setText("Name-Key: " + (shortId.isBlank() ? "—" : PREFIX + shortId + ".name")); int idx = formContainer.getChildren().indexOf(triggerEditor); triggerEditor = new TriggerListEditor( - loc.getTriggers() != null ? loc.getTriggers() : List.of(), () -> {}); + loc.getTriggers() != null ? loc.getTriggers() : List.of(), () -> scheduleSave()); if (idx >= 0) formContainer.getChildren().set(idx, triggerEditor); } private void saveFormToLocation(Location loc) { - String nameId = nameIdField.getText().trim(); - loc.setName(nameId.isBlank() ? null : new TextReference(nameId)); - loc.setCenterX(parseFloat(centerXField.getText())); - loc.setCenterZ(parseFloat(centerZField.getText())); - loc.setRadius(parseFloat(radiusField.getText())); + String shortId = idField.getText().trim(); + String fullId = shortId.isBlank() ? "" : PREFIX + shortId; + loc.setName(fullId.isBlank() ? null : new TextReference(fullId)); loc.setTriggers(triggerEditor.getTriggers()); } private void clearForm() { - nameIdField.clear(); - centerXField.clear(); - centerZField.clear(); - radiusField.clear(); + idField.clear(); + nameKeyLabel.setText("Name-Key: —"); } // ── List operations ─────────────────────────────────────────────────────── private void createLocation() { Location loc = new Location(); - loc.setName(new TextReference("location.neu_" + System.currentTimeMillis())); + loc.setName(new TextReference(PREFIX + "neu_" + System.currentTimeMillis())); locations.add(loc); listView.getSelectionModel().select(loc); } @@ -194,26 +201,10 @@ public class LocationEditorView extends BorderPane { reload(); } - private void saveCurrent() { - if (current == null) return; - saveFormToLocation(current); - if (current.getId().isBlank()) { - new Alert(Alert.AlertType.ERROR, "Name-ID darf nicht leer sein.", ButtonType.OK).showAndWait(); - return; - } - String savedId = current.getId(); - persist(); - reload(); - locations.stream() - .filter(l -> savedId.equals(l.getId())) - .findFirst() - .ifPresent(listView.getSelectionModel()::select); - } - private void persist() { try { LocationIO.save(locations); } catch (IOException e) { - new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); } } @@ -224,11 +215,6 @@ public class LocationEditorView extends BorderPane { // ── Helpers ─────────────────────────────────────────────────────────────── - private static float parseFloat(String s) { - try { return Float.parseFloat(s.trim().replace(',', '.')); } - catch (NumberFormatException ignored) { return 0f; } - } - 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;"); @@ -237,7 +223,7 @@ public class LocationEditorView extends BorderPane { private static HBox row(String labelText, Node control) { Label lbl = new Label(labelText); - lbl.setMinWidth(80); + lbl.setMinWidth(50); lbl.setStyle("-fx-text-fill: #aaa;"); HBox.setHgrow(control, Priority.ALWAYS); HBox box = new HBox(8, lbl, control); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/MapObjectsView.java b/blight-editor/src/main/java/de/blight/editor/ui/MapObjectsView.java index 2514920..42c82c6 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/MapObjectsView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/MapObjectsView.java @@ -115,11 +115,9 @@ public class MapObjectsView extends VBox { // ── Löschen ─────────────────────────────────────────────────────────────── private void confirmAndDelete(String label, Entry entry) { - Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + Alert alert = Dialogs.alert(Alert.AlertType.CONFIRMATION, label, ButtonType.YES, ButtonType.NO); alert.setTitle("Objekt löschen"); alert.setHeaderText("Objekt wirklich löschen?"); - alert.setContentText(label); - alert.getButtonTypes().setAll(ButtonType.YES, ButtonType.NO); Optional result = alert.showAndWait(); if (result.isEmpty() || result.get() != ButtonType.YES) return; @@ -128,8 +126,7 @@ public class MapObjectsView extends VBox { onDelete.execute(entry.placedObj(), entry.index(), entry.toolHint()); refresh(); } catch (Exception ex) { - Alert err = new Alert(Alert.AlertType.ERROR, "Fehler: " + ex.getMessage(), ButtonType.OK); - err.showAndWait(); + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + ex.getMessage(), ButtonType.OK).showAndWait(); } } @@ -243,12 +240,12 @@ public class MapObjectsView extends VBox { private void loadAreas() { try { List list = AreaIO.load(); - TreeItem group = group("Bereiche", list.size()); + TreeItem group = group("Areas", list.size()); for (int idx = 0; idx < list.size(); idx++) { PlacedArea a = list.get(idx); float cx = centroid(a.pointsX()), cz = centroid(a.pointsZ()); - String label = a.nameId() != null && !a.nameId().isBlank() - ? a.nameId() : "Bereich #" + (idx + 1); + String label = a.areaId() != null && !a.areaId().isBlank() + ? a.areaId() : "Area #" + (idx + 1); TreeItem item = leaf(label + " " + posXZ(cx, cz)); entryMap.put(item, new Entry(cx, Float.NaN, cz, "area", a, idx)); group.getChildren().add(item); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/MaterialChooser.java b/blight-editor/src/main/java/de/blight/editor/ui/MaterialChooser.java index 41da28f..368c995 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/MaterialChooser.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/MaterialChooser.java @@ -34,6 +34,7 @@ public class MaterialChooser extends Dialog { public MaterialChooser(Path matDefsRoot) { setTitle("Material auswählen"); initModality(Modality.APPLICATION_MODAL); + initOwner(Dialogs.primaryWindow()); setResizable(true); VBox contentBox = new VBox(12); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/ModelChooser.java b/blight-editor/src/main/java/de/blight/editor/ui/ModelChooser.java index b4ef627..5c8ad57 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/ModelChooser.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/ModelChooser.java @@ -39,6 +39,7 @@ public class ModelChooser extends Dialog { this.assetRoot = assetRoot; setTitle("Modell auswählen"); initModality(Modality.APPLICATION_MODAL); + initOwner(Dialogs.primaryWindow()); setResizable(true); contentBox.setPadding(new Insets(4)); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/MonologueEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/MonologueEditorView.java index a8fbdec..d7b96f9 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/MonologueEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/MonologueEditorView.java @@ -237,7 +237,7 @@ public class MonologueEditorView extends SplitPane { try { MonologueIO.save(new ArrayList<>(monologues)); } catch (IOException e) { - new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); } } @@ -253,7 +253,7 @@ public class MonologueEditorView extends SplitPane { private void deleteMonologue() { Monologue sel = listView.getSelectionModel().getSelectedItem(); if (sel == null) return; - Alert confirm = new Alert(Alert.AlertType.CONFIRMATION, + Alert confirm = Dialogs.alert(Alert.AlertType.CONFIRMATION, "Monolog '" + sel.getId() + "' wirklich löschen?", ButtonType.YES, ButtonType.NO); confirm.showAndWait().ifPresent(bt -> { if (bt == ButtonType.YES) { @@ -296,6 +296,7 @@ public class MonologueEditorView extends SplitPane { Dialog dlg = new Dialog<>(); dlg.setTitle("Quest auswählen"); dlg.initModality(Modality.APPLICATION_MODAL); + dlg.initOwner(Dialogs.primaryWindow()); ListView chooser = new ListView<>(); chooser.setCellFactory(lv -> new ListCell<>() { diff --git a/blight-editor/src/main/java/de/blight/editor/ui/QuestEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/QuestEditorView.java index 5b3bfea..413b3c0 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/QuestEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/QuestEditorView.java @@ -7,6 +7,7 @@ import de.blight.common.model.NPC; import de.blight.common.model.Location; import de.blight.common.model.TextReference; import de.blight.common.model.quests.*; +import javafx.animation.PauseTransition; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; @@ -14,6 +15,7 @@ import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.*; +import javafx.util.Duration; import java.io.IOException; import java.nio.file.Path; @@ -43,13 +45,19 @@ public class QuestEditorView extends BorderPane { private ComboBox typeCombo; private VBox dynamicArea; private VBox formContainer; - private Button saveBtn; // ── Type-specific fields ────────────────────────────────────────────────── private TextField f1, f2, f3; private Spinner countSpinner; + private final PauseTransition savePause = new PauseTransition(Duration.millis(600)); + private boolean reloading = false; + + { + savePause.setOnFinished(e -> persistCurrent()); + } + public QuestEditorView(Path questDir) { this.questDir = questDir; setStyle("-fx-background-color: #1e1e2e;"); @@ -134,17 +142,22 @@ public class QuestEditorView extends BorderPane { idField.focusedProperty().addListener((obs, wasFocused, isFocused) -> { if (!isFocused) autoFillTextRefs(); }); + idField.textProperty().addListener((obs, o, n) -> scheduleSave()); xpSpinner = new Spinner<>(0, 99999, 0); xpSpinner.setEditable(true); xpSpinner.setMaxWidth(Double.MAX_VALUE); + xpSpinner.valueProperty().addListener((obs, o, n) -> scheduleSave()); textField = new TextField(); textField.setPromptText("TextReference-Schlüssel"); + textField.textProperty().addListener((obs, o, n) -> scheduleSave()); descField = new TextField(); descField.setPromptText("TextReference-Schlüssel"); + descField.textProperty().addListener((obs, o, n) -> scheduleSave()); successField = new TextField(); successField.setPromptText("TextReference-Schlüssel"); + successField.textProperty().addListener((obs, o, n) -> scheduleSave()); form.getChildren().addAll( sectionTitle("Quest"), @@ -163,7 +176,7 @@ public class QuestEditorView extends BorderPane { typeCombo.getItems().addAll("BringQuest", "FollowQuest", "InteractQuest", "ItemQuest", "TalkQuest"); typeCombo.setPromptText("Typ auswählen…"); typeCombo.setMaxWidth(Double.MAX_VALUE); - typeCombo.setOnAction(e -> rebuildDynamicArea(typeCombo.getValue())); + typeCombo.setOnAction(e -> { rebuildDynamicArea(typeCombo.getValue()); scheduleSave(); }); dynamicArea = new VBox(6); @@ -174,12 +187,6 @@ public class QuestEditorView extends BorderPane { new Separator() ); - saveBtn = new Button("Quest speichern"); - saveBtn.setMaxWidth(Double.MAX_VALUE); - saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); - saveBtn.setOnAction(e -> saveCurrentQuest()); - form.getChildren().add(saveBtn); - return form; } @@ -243,10 +250,40 @@ public class QuestEditorView extends BorderPane { } } + // ── Auto-save ───────────────────────────────────────────────────────────── + + private void scheduleSave() { + savePause.playFromStart(); + } + + private void persistCurrent() { + if (current == null || reloading) { return; } + Quest built = buildQuestFromForm(); + if (built == null || built.getQuestId() == null || built.getQuestId().isBlank()) { return; } + int idx = quests.indexOf(current); + if (idx >= 0) { quests.set(idx, built); } + else { quests.add(built); } + current = built; + try { + QuestIO.save(built, questDir); + } catch (IOException e) { + showError("Fehler beim Speichern: " + e.getMessage()); + } + } + // ── Form load / save ────────────────────────────────────────────────────── private void onQuestSelected(Quest old, Quest nw) { - if (old != null) saveFormToQuest(old); + if (reloading) { return; } + if (old != null) { + saveFormToQuest(old); + Quest built = buildQuestFromForm(); + if (built != null && built.getQuestId() != null && !built.getQuestId().isBlank()) { + int idx = quests.indexOf(old); + if (idx >= 0) { quests.set(idx, built); } + try { QuestIO.save(built, questDir); } catch (IOException e) { /* ignore on navigation */ } + } + } current = nw; deleteBtn.setDisable(nw == null); if (nw != null) { @@ -406,30 +443,6 @@ public class QuestEditorView extends BorderPane { reload(); } - private void saveCurrentQuest() { - Quest built = buildQuestFromForm(); - if (built == null) { - showError("Bitte einen Typ wählen."); - return; - } - if (built.getQuestId() == null || built.getQuestId().isBlank()) { - showError("Quest-ID darf nicht leer sein."); - return; - } - int idx = quests.indexOf(current); - if (idx >= 0) quests.set(idx, built); - else quests.add(built); - current = built; - listView.getSelectionModel().select(built); - try { - QuestIO.save(built, questDir); - } catch (IOException e) { - showError("Fehler beim Speichern: " + e.getMessage()); - return; - } - reload(); - } - public void reload() { List loaded = QuestIO.loadAll(questDir); quests.setAll(loaded); @@ -467,7 +480,6 @@ public class QuestEditorView extends BorderPane { } private void showError(String msg) { - Alert a = new Alert(Alert.AlertType.ERROR, msg, ButtonType.OK); - a.showAndWait(); + Dialogs.alert(Alert.AlertType.ERROR, msg, ButtonType.OK).showAndWait(); } } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java index 8ae93ab..ccf1049 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java @@ -1,6 +1,7 @@ package de.blight.editor.ui; import de.blight.common.model.*; +import javafx.animation.PauseTransition; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; @@ -10,6 +11,7 @@ import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.*; import javafx.stage.Modality; +import javafx.util.Duration; import java.io.IOException; import java.nio.file.Path; @@ -49,6 +51,12 @@ public class RecipeEditorView extends BorderPane { private Spinner engineeringSpinner; private VBox formContainer; + private final PauseTransition savePause = new PauseTransition(Duration.millis(600)); + + { + savePause.setOnFinished(e -> persistCurrent()); + } + public RecipeEditorView(Path recipeDir) { this.recipeDir = recipeDir; this.sortedRecipes = new SortedList<>(recipes, RecipeIO.SORT_ORDER); @@ -129,6 +137,7 @@ public class RecipeEditorView extends BorderPane { createsField = new TextField(); createsField.setPromptText("Item-ID des erstellten Items"); + createsField.textProperty().addListener((obs, o, n) -> scheduleSave()); componentsList = new ListView<>(); componentsList.setPrefHeight(110); @@ -156,7 +165,7 @@ public class RecipeEditorView extends BorderPane { } tableCombo.setValue(NO_TABLE); tableCombo.setMaxWidth(Double.MAX_VALUE); - tableCombo.setOnAction(e -> updateRequirementRows(tableCombo.getValue())); + tableCombo.setOnAction(e -> { updateRequirementRows(tableCombo.getValue()); scheduleSave(); }); alchemySpinner = lvlSpinner(); enchantingSpinner = lvlSpinner(); @@ -168,11 +177,6 @@ public class RecipeEditorView extends BorderPane { smitheryRow = requirementRow("Lvl Schmieden:", smitherySpinner); engineeringRow = requirementRow("Lvl Engineering:", engineeringSpinner); - Button saveBtn = new Button("Rezept speichern"); - saveBtn.setMaxWidth(Double.MAX_VALUE); - saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); - saveBtn.setOnAction(e -> saveCurrentRecipe()); - form.getChildren().addAll( sectionTitle("Ergebnis"), new Separator(), @@ -186,9 +190,7 @@ public class RecipeEditorView extends BorderPane { alchemyRow, enchantingRow, smitheryRow, - engineeringRow, - new Separator(), - saveBtn + engineeringRow ); updateRequirementRows(NO_TABLE); @@ -216,10 +218,38 @@ public class RecipeEditorView extends BorderPane { row.setManaged(visible); } + // ── Auto-save ───────────────────────────────────────────────────────────── + + private void scheduleSave() { + savePause.playFromStart(); + } + + private void persistCurrent() { + if (current == null) { return; } + saveFormToRecipe(current); + String newFileId = RecipeIO.fileId(current); + if (newFileId.startsWith("unbenanntes")) { return; } + try { + if (oldFileId != null && !oldFileId.equals(newFileId)) { + RecipeIO.delete(oldFileId, recipeDir); + } + RecipeIO.save(current, recipeDir); + oldFileId = newFileId; + } catch (IOException e) { + Dialogs.alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + } + } + // ── Form load / save ────────────────────────────────────────────────────── private void onRecipeSelected(Recipe old, Recipe nw) { - if (old != null) saveFormToRecipe(old); + if (old != null) { + saveFormToRecipe(old); + String fid = RecipeIO.fileId(old); + if (!fid.startsWith("unbenanntes")) { + try { RecipeIO.save(old, recipeDir); } catch (IOException e) { /* ignore on navigation */ } + } + } current = nw; oldFileId = nw != null ? RecipeIO.fileId(nw) : null; deleteBtn.setDisable(nw == null); @@ -342,38 +372,11 @@ public class RecipeEditorView extends BorderPane { reload(); } - private void saveCurrentRecipe() { - if (current == null) return; - saveFormToRecipe(current); - - String newFileId = RecipeIO.fileId(current); - if (newFileId.startsWith("unbenanntes")) { - new Alert(Alert.AlertType.ERROR, - "Item-ID des erstellten Items darf nicht leer sein.", ButtonType.OK).showAndWait(); - return; - } - try { - if (oldFileId != null && !oldFileId.equals(newFileId)) { - RecipeIO.delete(oldFileId, recipeDir); - } - RecipeIO.save(current, recipeDir); - oldFileId = newFileId; - } catch (IOException e) { - new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); - return; - } - reload(); - final String fid = newFileId; - recipes.stream() - .filter(r -> fid.equals(RecipeIO.fileId(r))) - .findFirst() - .ifPresent(listView.getSelectionModel()::select); - } - private void addComponent() { Dialog dlg = new Dialog<>(); dlg.setTitle("Zutat hinzufügen"); dlg.initModality(Modality.APPLICATION_MODAL); + dlg.initOwner(Dialogs.primaryWindow()); TextField itemIdField = new TextField(); itemIdField.setPromptText("Item-ID"); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/RoutineEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/RoutineEditorView.java index 08f2e2c..1cff012 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/RoutineEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/RoutineEditorView.java @@ -462,6 +462,7 @@ public class RoutineEditorView extends VBox { private void addRoutine() { TextInputDialog dlg = new TextInputDialog("Routine " + (routines.size() + 1)); + dlg.initOwner(Dialogs.primaryWindow()); dlg.setHeaderText("Name des Tagesablaufs:"); dlg.showAndWait().ifPresent(name -> { if (name.isBlank()) return; @@ -475,6 +476,7 @@ public class RoutineEditorView extends VBox { private void renameRoutine() { if (activeRoutine == null) return; TextInputDialog dlg = new TextInputDialog(activeRoutine.getName()); + dlg.initOwner(Dialogs.primaryWindow()); dlg.setHeaderText("Neuer Name:"); dlg.showAndWait().ifPresent(name -> { if (!name.isBlank()) { diff --git a/blight-editor/src/main/java/de/blight/editor/ui/SoundChooser.java b/blight-editor/src/main/java/de/blight/editor/ui/SoundChooser.java new file mode 100644 index 0000000..65ee013 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/SoundChooser.java @@ -0,0 +1,166 @@ +package de.blight.editor.ui; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.scene.media.Media; +import javafx.scene.media.MediaPlayer; +import javafx.stage.Modality; + +import java.nio.file.*; +import java.util.*; +import java.util.stream.Stream; + +/** + * List-based sound/music picker dialog. + * + * Two modes: + * MUSIC — scans only {@code audio/music/} inside the asset root. + * ALL — scans the entire {@code audio/} tree. + * + * Returns the selected JME asset path (relative to asset root) or {@code null} on cancel. + * Double-click or OK confirms the selection; a small play button previews the file. + */ +public class SoundChooser extends Dialog { + + public enum Mode { MUSIC, ALL } + + private static final Set AUDIO_EXTS = Set.of(".ogg", ".wav", ".mp3"); + + private final List allPaths = new ArrayList<>(); + private final ListView listView = new ListView<>(); + private final TextField filterField = new TextField(); + private MediaPlayer preview = null; + private String selected = null; + + public SoundChooser(Path assetRoot, Mode mode) { + setTitle(mode == Mode.MUSIC ? "Musik auswählen" : "Sound auswählen"); + initModality(Modality.APPLICATION_MODAL); + initOwner(Dialogs.primaryWindow()); + setResizable(true); + + // ── scan files ──────────────────────────────────────────────────────── + if (assetRoot != null) { + Path scanRoot = mode == Mode.MUSIC + ? assetRoot.resolve("audio/music") + : assetRoot.resolve("audio"); + if (Files.isDirectory(scanRoot)) { + try (Stream walk = Files.walk(scanRoot)) { + walk.filter(Files::isRegularFile) + .filter(p -> isAudioFile(p.getFileName().toString())) + .sorted(Comparator.comparing(p -> assetRoot.relativize(p).toString().toLowerCase())) + .forEach(p -> allPaths.add(assetRoot.relativize(p).toString().replace('\\', '/'))); + } catch (Exception ignored) {} + } + } + listView.getItems().setAll(allPaths); + listView.setPrefHeight(420); + + // ── filter ──────────────────────────────────────────────────────────── + filterField.setPromptText("Filtern…"); + filterField.textProperty().addListener((obs, o, n) -> applyFilter(n == null ? "" : n)); + + // ── preview bar ─────────────────────────────────────────────────────── + Button playBtn = new Button("▶"); + Button stopBtn = new Button("■"); + stopBtn.setDisable(true); + Label previewLbl = new Label("(nichts gewählt)"); + previewLbl.setStyle("-fx-text-fill: #666; -fx-font-size: 11;"); + + playBtn.setOnAction(e -> { + stopPreview(); + String sel = listView.getSelectionModel().getSelectedItem(); + if (sel == null || assetRoot == null) return; + Path file = assetRoot.resolve(sel.replace('/', java.io.File.separatorChar)); + try { + Media media = new Media(file.toUri().toString()); + preview = new MediaPlayer(media); + preview.setOnEndOfMedia(() -> { + stopBtn.setDisable(true); + playBtn.setDisable(false); + }); + preview.play(); + stopBtn.setDisable(false); + playBtn.setDisable(true); + } catch (Exception ex) { + previewLbl.setText("Fehler: " + ex.getMessage()); + } + }); + stopBtn.setOnAction(e -> { + stopPreview(); + stopBtn.setDisable(true); + playBtn.setDisable(false); + }); + + HBox previewBar = new HBox(6, playBtn, stopBtn, previewLbl); + previewBar.setAlignment(Pos.CENTER_LEFT); + previewBar.setPadding(new Insets(4, 0, 0, 0)); + + // ── empty hint ──────────────────────────────────────────────────────── + if (allPaths.isEmpty()) { + String hint = mode == Mode.MUSIC + ? "Keine Musik gefunden.\nDateien nach audio/music/ importieren." + : "Keine Audio-Dateien gefunden."; + listView.setPlaceholder(new Label(hint)); + } + + // ── layout ──────────────────────────────────────────────────────────── + VBox root = new VBox(8, filterField, listView, previewBar); + root.setPadding(new Insets(10)); + + getDialogPane().setContent(root); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + getDialogPane().setPrefSize(480, 540); + + // ── selection tracking (buttons must exist before lookup) ───────────── + Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK); + if (okBtn != null) okBtn.setDisable(true); + + listView.getSelectionModel().selectedItemProperty().addListener((obs, o, n) -> { + selected = n; + previewLbl.setText(n != null ? lastName(n) : "(nichts gewählt)"); + stopPreview(); stopBtn.setDisable(true); playBtn.setDisable(n == null); + if (okBtn != null) okBtn.setDisable(n == null); + }); + + listView.setOnMouseClicked(e -> { + if (e.getClickCount() == 2 && selected != null) { + if (okBtn != null) okBtn.fire(); + } + }); + + setOnHidden(e -> stopPreview()); + setResultConverter(btn -> btn == ButtonType.OK ? selected : null); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private void applyFilter(String raw) { + String lo = raw.toLowerCase(); + if (lo.isBlank()) { + listView.getItems().setAll(allPaths); + } else { + listView.getItems().setAll( + allPaths.stream().filter(p -> p.toLowerCase().contains(lo)).toList()); + } + } + + private void stopPreview() { + if (preview != null) { + preview.stop(); + preview.dispose(); + preview = null; + } + } + + private static boolean isAudioFile(String name) { + String lo = name.toLowerCase(); + return AUDIO_EXTS.stream().anyMatch(lo::endsWith); + } + + private static String lastName(String path) { + int i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return i >= 0 ? path.substring(i + 1) : path; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TextureChooser.java b/blight-editor/src/main/java/de/blight/editor/ui/TextureChooser.java index 1aee72d..41c8f95 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/TextureChooser.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/TextureChooser.java @@ -73,6 +73,7 @@ public class TextureChooser extends Dialog { public TextureChooser(Path assetRoot, boolean includeJmeBuiltin) { setTitle("Textur auswählen"); initModality(Modality.APPLICATION_MODAL); + initOwner(Dialogs.primaryWindow()); setResizable(true); contentBox.setPadding(new Insets(4)); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java b/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java index c12bd5e..7aa3333 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java @@ -5,6 +5,8 @@ import de.blight.common.model.Monologue; import de.blight.common.model.QuestRef; import de.blight.common.model.Status; import de.blight.common.model.trigger.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; @@ -12,19 +14,9 @@ import javafx.scene.control.*; import javafx.scene.layout.*; import javafx.stage.Modality; +import java.util.ArrayList; import java.util.UUID; -/** - * Modal-Dialog zum Anlegen oder Bearbeiten eines {@link Trigger}. - * - *

Ablauf: - *

    - *
  1. Typ-ComboBox wählen
  2. - *
  3. Typ-spezifische Felder füllen
  4. - *
  5. Kapitel-Anforderung (optional)
  6. - *
- * Rückgabewert: der fertig gebaute {@link Trigger} oder {@code null} bei Abbruch. - */ public class TriggerDialog extends Dialog { private static final String TYPE_QUEST = "Quest starten"; @@ -32,6 +24,7 @@ public class TriggerDialog extends Dialog { private static final String TYPE_FRACTION = "Fraktions-Status ändern"; private static final String TYPE_ROUTINE = "Routine ändern"; private static final String TYPE_MONOLOGUE = "Monolog starten"; + private static final String TYPE_NPC_DIALOG = "NPC-Dialog starten"; // Gemeinsam private final ComboBox typeCombo = new ComboBox<>(); @@ -56,33 +49,89 @@ public class TriggerDialog extends Dialog { // Monolog private ComboBox monologueIdCombo; - /** Öffnet den Dialog für einen neuen Trigger. */ + // NPC-Dialog + private TextField npcDialogIdField; + + // Bedingungen + private final ComboBox conditionModeCombo = new ComboBox<>(); + private final ObservableList conditions = FXCollections.observableArrayList(); + private final ListView conditionList = new ListView<>(conditions); + public TriggerDialog() { this(null); } - /** Öffnet den Dialog im Bearbeitungsmodus mit einem vorhandenen Trigger. */ public TriggerDialog(Trigger existing) { setTitle(existing == null ? "Trigger hinzufügen" : "Trigger bearbeiten"); initModality(Modality.APPLICATION_MODAL); + initOwner(Dialogs.primaryWindow()); setResizable(true); - typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION, TYPE_ROUTINE, TYPE_MONOLOGUE); + typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION, TYPE_ROUTINE, + TYPE_MONOLOGUE, TYPE_NPC_DIALOG); typeCombo.setMaxWidth(Double.MAX_VALUE); typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue())); chapterSpinner.setEditable(true); chapterSpinner.setPrefWidth(80); + conditionModeCombo.getItems().addAll("ALL – alle müssen gelten", "ANY – mindestens eine"); + conditionModeCombo.setValue("ALL – alle müssen gelten"); + conditionModeCombo.setMaxWidth(Double.MAX_VALUE); + + conditionList.setPrefHeight(90); + conditionList.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(Condition c, boolean empty) { + super.updateItem(c, empty); + setText(empty || c == null ? null : ConditionDialog.describe(c)); + setStyle(empty ? "" : "-fx-font-size: 11;"); + } + }); + + Button addCondBtn = new Button("+"); + Button editCondBtn = new Button("✎"); + Button delCondBtn = new Button("-"); + editCondBtn.setDisable(true); + delCondBtn.setDisable(true); + conditionList.getSelectionModel().selectedItemProperty().addListener((obs, o, n) -> { + boolean sel = n != null; + editCondBtn.setDisable(!sel); + delCondBtn.setDisable(!sel); + }); + addCondBtn.setOnAction(e -> + new ConditionDialog().showAndWait().ifPresent(conditions::add)); + editCondBtn.setOnAction(e -> { + Condition sel = conditionList.getSelectionModel().getSelectedItem(); + if (sel == null) return; + new ConditionDialog(sel).showAndWait().ifPresent(updated -> { + int idx = conditions.indexOf(sel); + if (idx >= 0) conditions.set(idx, updated); + }); + }); + delCondBtn.setOnAction(e -> { + Condition sel = conditionList.getSelectionModel().getSelectedItem(); + if (sel != null) conditions.remove(sel); + }); + + HBox condButtons = new HBox(4, addCondBtn, editCondBtn, delCondBtn); + condButtons.setPadding(new Insets(2, 0, 0, 0)); + + Label condSectionLbl = new Label("Bedingungen"); + condSectionLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 12;"); + VBox content = new VBox(10); content.setPadding(new Insets(16)); - content.setPrefWidth(400); - + content.setPrefWidth(420); content.getChildren().addAll( - row("Trigger-Typ:", typeCombo), + row("Trigger-Typ:", typeCombo), row("Kapitel (mind.):", chapterSpinner), new Separator(), - dynamicArea + dynamicArea, + new Separator(), + condSectionLbl, + row("Verknüpfung:", conditionModeCombo), + conditionList, + condButtons ); getDialogPane().setContent(content); @@ -90,14 +139,10 @@ public class TriggerDialog extends Dialog { Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK); okBtn.setDisable(true); - - // Validierung: OK nur wenn Typ gewählt und Pflichtfelder gefüllt typeCombo.valueProperty().addListener((obs, o, n) -> okBtn.setDisable(n == null)); - // Ergebnis-Konverter setResultConverter(bt -> bt == ButtonType.OK ? buildTrigger() : null); - // Vorhandenen Trigger laden if (existing != null) preload(existing); else typeCombo.setValue(TYPE_QUEST); } @@ -108,20 +153,18 @@ public class TriggerDialog extends Dialog { dynamicArea.getChildren().clear(); if (type == null) return; switch (type) { - case TYPE_QUEST -> buildQuestFields(); - case TYPE_NPC -> buildNpcFields(); - case TYPE_FRACTION -> buildFractionFields(); - case TYPE_ROUTINE -> buildRoutineFields(); - case TYPE_MONOLOGUE -> buildMonologueFields(); + case TYPE_QUEST -> buildQuestFields(); + case TYPE_NPC -> buildNpcFields(); + case TYPE_FRACTION -> buildFractionFields(); + case TYPE_ROUTINE -> buildRoutineFields(); + case TYPE_MONOLOGUE -> buildMonologueFields(); + case TYPE_NPC_DIALOG -> buildNpcDialogFields(); } } private void buildQuestFields() { questIdField = field("Quest-ID (z. B. q_main_001)"); - dynamicArea.getChildren().addAll( - sectionTitle("Quest"), - row("Quest-ID:", questIdField) - ); + dynamicArea.getChildren().addAll(sectionTitle("Quest"), row("Quest-ID:", questIdField)); } private void buildNpcFields() { @@ -129,7 +172,7 @@ public class TriggerDialog extends Dialog { npcStatusCombo = statusCombo(); dynamicArea.getChildren().addAll( sectionTitle("NPC-Status"), - row("NPC-ID:", npcIdField), + row("NPC-ID:", npcIdField), row("Neuer Status:", npcStatusCombo) ); } @@ -139,8 +182,8 @@ public class TriggerDialog extends Dialog { fractionStatusCombo = statusCombo(); dynamicArea.getChildren().addAll( sectionTitle("Fraktions-Status"), - row("Fraktions-UUID:", fractionIdField), - row("Neuer Status:", fractionStatusCombo) + row("Fraktions-UUID:", fractionIdField), + row("Neuer Status:", fractionStatusCombo) ); } @@ -149,8 +192,8 @@ public class TriggerDialog extends Dialog { routineNameField = field("Name der Routine"); dynamicArea.getChildren().addAll( sectionTitle("Routine ändern"), - row("NPC-ID:", routineNpcIdField), - row("Routine-Name:", routineNameField) + row("NPC-ID:", routineNpcIdField), + row("Routine-Name:", routineNameField) ); } @@ -160,14 +203,19 @@ public class TriggerDialog extends Dialog { monologueIdCombo.setMaxWidth(Double.MAX_VALUE); monologueIdCombo.setPromptText("Monolog-ID eingeben oder wählen..."); try { - de.blight.common.MonologueIO.load().stream() + MonologueIO.load().stream() .map(Monologue::getId) .filter(id -> !id.isBlank()) .forEach(monologueIdCombo.getItems()::add); } catch (Exception ignored) {} + dynamicArea.getChildren().addAll(sectionTitle("Monolog starten"), row("Monolog-ID:", monologueIdCombo)); + } + + private void buildNpcDialogFields() { + npcDialogIdField = field("Character-ID des NPCs"); dynamicArea.getChildren().addAll( - sectionTitle("Monolog starten"), - row("Monolog-ID:", monologueIdCombo) + sectionTitle("NPC-Dialog starten"), + row("NPC-ID:", npcDialogIdField) ); } @@ -188,7 +236,7 @@ public class TriggerDialog extends Dialog { } case TYPE_NPC -> { NpcStatusTrigger n = new NpcStatusTrigger(); - if (npcIdField != null) n.setNpcId(npcIdField.getText().trim()); + if (npcIdField != null) n.setNpcId(npcIdField.getText().trim()); if (npcStatusCombo != null) n.setTargetStatus(npcStatusCombo.getValue()); yield n; } @@ -215,41 +263,56 @@ public class TriggerDialog extends Dialog { } yield mo; } + case TYPE_NPC_DIALOG -> { + NpcDialogTrigger nd = new NpcDialogTrigger(); + if (npcDialogIdField != null) nd.setNpcId(npcDialogIdField.getText().trim()); + yield nd; + } default -> null; }; - if (t != null) t.setRequiresChapter(chapterSpinner.getValue()); + if (t == null) return null; + t.setRequiresChapter(chapterSpinner.getValue()); + String modeStr = conditionModeCombo.getValue(); + t.setConditionMode(modeStr != null && modeStr.startsWith("ANY") ? ConditionMode.ANY : ConditionMode.ALL); + t.setConditions(new ArrayList<>(conditions)); return t; } - // ── Vorhandenen Trigger vorfüllen ───────────────────────────────────────── + // ── Vorladen ────────────────────────────────────────────────────────────── private void preload(Trigger t) { chapterSpinner.getValueFactory().setValue(t.getRequiresChapter()); + conditionModeCombo.setValue(t.getConditionMode() == ConditionMode.ANY + ? "ANY – mindestens eine" : "ALL – alle müssen gelten"); + if (t.getConditions() != null) conditions.setAll(t.getConditions()); + if (t instanceof QuestStartTrigger q) { typeCombo.setValue(TYPE_QUEST); if (questIdField != null && q.getQuest() != null) questIdField.setText(nullSafe(q.getQuest().getQuestId())); } else if (t instanceof NpcStatusTrigger n) { typeCombo.setValue(TYPE_NPC); - if (npcIdField != null) npcIdField.setText(nullSafe(n.getNpcId())); + if (npcIdField != null) npcIdField.setText(nullSafe(n.getNpcId())); if (npcStatusCombo != null && n.getTargetStatus() != null) npcStatusCombo.setValue(n.getTargetStatus()); } else if (t instanceof FractionStatusTrigger f) { typeCombo.setValue(TYPE_FRACTION); - if (fractionIdField != null && f.getFractionId() != null) + if (fractionIdField != null && f.getFractionId() != null) fractionIdField.setText(f.getFractionId().toString()); if (fractionStatusCombo != null && f.getTargetStatus() != null) fractionStatusCombo.setValue(f.getTargetStatus()); } else if (t instanceof ChangeRoutineTrigger r) { typeCombo.setValue(TYPE_ROUTINE); - if (routineNpcIdField != null && r.getNpcId() != null) - routineNpcIdField.setText(r.getNpcId()); - if (routineNameField != null && r.getRoutineName() != null) - routineNameField.setText(r.getRoutineName()); + if (routineNpcIdField != null && r.getNpcId() != null) routineNpcIdField.setText(r.getNpcId()); + if (routineNameField != null && r.getRoutineName() != null) routineNameField.setText(r.getRoutineName()); } else if (t instanceof MonologueTrigger mo) { typeCombo.setValue(TYPE_MONOLOGUE); if (monologueIdCombo != null && mo.getMonologueId() != null) monologueIdCombo.setValue(mo.getMonologueId()); + } else if (t instanceof NpcDialogTrigger nd) { + typeCombo.setValue(TYPE_NPC_DIALOG); + if (npcDialogIdField != null && nd.getNpcId() != null) + npcDialogIdField.setText(nd.getNpcId()); } } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java b/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java index 9609131..792aa42 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java @@ -111,6 +111,8 @@ public class TriggerListEditor extends VBox { + " -> \"" + nullSafe(r.getRoutineName()) + "\"" + chapter; if (t instanceof MonologueTrigger mo) return "Monolog: " + nullSafe(mo.getMonologueId()) + chapter; + if (t instanceof NpcDialogTrigger nd) + return "NPC-Dialog: " + nullSafe(nd.getNpcId()) + chapter; return t.getClass().getSimpleName() + chapter; } diff --git a/blight-game/src/main/java/de/blight/game/console/JmeConsole.java b/blight-game/src/main/java/de/blight/game/console/JmeConsole.java index 02c5f76..efcf668 100644 --- a/blight-game/src/main/java/de/blight/game/console/JmeConsole.java +++ b/blight-game/src/main/java/de/blight/game/console/JmeConsole.java @@ -222,7 +222,8 @@ public class JmeConsole extends BaseAppState { private BitmapText makeLine(BitmapFont font, ColorRGBA color, float x, float y) { BitmapText t = new BitmapText(font); - t.setSize(LINE_H - 2f); + t.setSize((LINE_H - 2f) * 2f); + t.setLocalScale(0.5f, 0.5f, 1f); t.setColor(color); t.setLocalTranslation(x, y, 0f); return t; diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index 66bb1a9..1fa6d92 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -102,6 +102,7 @@ public class WorldScene extends BaseAppState { private float spawnYaw = 0f; private de.blight.game.state.OceanSoundState oceanSound; private de.blight.game.state.AmbientSoundSystem ambientSounds; + private de.blight.game.state.MusicSystem musicSystem; private de.blight.game.audio.FootstepSystem footstepSystem; private de.blight.game.state.DrownState drownState; private WaterInteractionState waterInteractionState; @@ -430,8 +431,12 @@ public class WorldScene extends BaseAppState { app.getListener().setRotation(app.getCamera().getRotation()); if (oceanSound != null) oceanSound.setPlayerPosition(pos); if (ambientSounds != null) ambientSounds.setPlayerPosition(pos); + if (musicSystem != null) musicSystem.setPlayerPosition(pos); } + if (musicSystem != null && dayNight != null) + musicSystem.setDaytime(dayNight.getSunElevation() >= 0f); + if (terrainMaterial != null && dayNight != null && dayNight.getSunLight() != null) { terrainMaterial.setVector3("LightDir", dayNight.getSunDirection().negate()); ColorRGBA sc = dayNight.getSunLight().getColor(); @@ -945,6 +950,9 @@ public class WorldScene extends BaseAppState { ambientSounds = new de.blight.game.state.AmbientSoundSystem(); app.getStateManager().attach(ambientSounds); + + musicSystem = new de.blight.game.state.MusicSystem(); + app.getStateManager().attach(musicSystem); } // ----------------------------------------------------------------------- diff --git a/blight-game/src/main/java/de/blight/game/state/DayNightState.java b/blight-game/src/main/java/de/blight/game/state/DayNightState.java index 3af6745..0d8ddf0 100644 --- a/blight-game/src/main/java/de/blight/game/state/DayNightState.java +++ b/blight-game/src/main/java/de/blight/game/state/DayNightState.java @@ -285,6 +285,14 @@ public class DayNightState extends BaseAppState implements TimeListener { } cc.a = 1f; cloudMat.setColor("CloudColor", cc); + + // Abendrot-Glut auf Wolkenunterseite (sunElevation: +1=Zenit, 0=Horizont, −1=Nadir) + // Sichtbar: Sonne bis +8.6° über Horizont (elev=+0.15) bis −5.7° darunter (Afterglow) + float glowCeil = FastMath.clamp(1f - sunElevation / 0.45f, 0f, 1f); // blendet über ~26° aus + float glowFade = FastMath.clamp((sunElevation + 0.10f) / 0.06f, 0f, 1f); // blendet unter −5.7° aus + float sunsetT = glowCeil * glowFade; + sunsetT = sunsetT * sunsetT; // weichere Kurve + cloudMat.setVector3("SunsetGlow", new Vector3f(0.42f * sunsetT, 0.13f * sunsetT, 0f)); } // Sonnenlicht + Schatten: Okklusion durch CloudDome bestimmt beides diff --git a/blight-game/src/main/java/de/blight/game/state/DialogHudState.java b/blight-game/src/main/java/de/blight/game/state/DialogHudState.java index c2b6587..6352d53 100644 --- a/blight-game/src/main/java/de/blight/game/state/DialogHudState.java +++ b/blight-game/src/main/java/de/blight/game/state/DialogHudState.java @@ -867,7 +867,8 @@ public class DialogHudState extends BaseAppState { private BitmapText makeTxt(String s, float size, ColorRGBA color) { BitmapText t = new BitmapText(font); - t.setSize(size); + t.setSize(size * 2f); + t.setLocalScale(0.5f, 0.5f, 1f); t.setColor(color); t.setText(s); return t; diff --git a/blight-game/src/main/java/de/blight/game/state/HotbarState.java b/blight-game/src/main/java/de/blight/game/state/HotbarState.java index 27d6dcf..e461c0c 100644 --- a/blight-game/src/main/java/de/blight/game/state/HotbarState.java +++ b/blight-game/src/main/java/de/blight/game/state/HotbarState.java @@ -264,7 +264,8 @@ public class HotbarState extends BaseAppState { private BitmapText txt(String s, int size, ColorRGBA col) { BitmapText t = new BitmapText(font); - t.setSize(size); + t.setSize(size * 2f); + t.setLocalScale(0.5f, 0.5f, 1f); t.setColor(col); t.setText(s); t.setQueueBucket(RenderQueue.Bucket.Gui); diff --git a/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java b/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java index 5ddeb92..087e3c3 100644 --- a/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java +++ b/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java @@ -43,7 +43,8 @@ public class InteractionHudState extends BaseAppState { BitmapFont font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt"); labelText = new BitmapText(font); - labelText.setSize(font.getCharSet().getRenderedSize() * 1.2f); + labelText.setSize(font.getCharSet().getRenderedSize() * 1.2f * 2f); + labelText.setLocalScale(0.5f, 0.5f, 1f); labelText.setColor(new ColorRGBA(1f, 0.95f, 0.6f, 1f)); labelText.setCullHint(Spatial.CullHint.Always); guiNode.attachChild(labelText); diff --git a/blight-game/src/main/java/de/blight/game/state/MusicSystem.java b/blight-game/src/main/java/de/blight/game/state/MusicSystem.java index 94411af..74f14eb 100644 --- a/blight-game/src/main/java/de/blight/game/state/MusicSystem.java +++ b/blight-game/src/main/java/de/blight/game/state/MusicSystem.java @@ -6,63 +6,107 @@ import com.jme3.app.state.BaseAppState; import com.jme3.asset.AssetManager; import com.jme3.audio.AudioData; import com.jme3.audio.AudioNode; +import com.jme3.audio.AudioSource; +import com.jme3.font.BitmapFont; +import com.jme3.font.BitmapText; +import com.jme3.math.ColorRGBA; import com.jme3.math.Vector3f; import com.jme3.scene.Node; +import de.blight.common.AreaDefinition; +import de.blight.common.AreaDefinitionIO; import de.blight.common.AreaIO; import de.blight.common.PlacedArea; +import de.blight.lang.TextResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** - * Proximity-based ambient music per polygon area. - * Three parallel tracks (day / night / combat) play when the player is inside. - * All three are attached + played on entry and detached on exit. - * Which one is actually audible is controlled via volume (wiring to day/night deferred). + * Area-based music system. + * + * Track priority: combat (not yet implemented) > night > day (fallback). + * On area entry the appropriate track fades in. + * On day/night change the running track finishes its current loop, + * then the next track starts (no abrupt cut). + * Day track is always the fallback when no other track is configured. */ public class MusicSystem extends BaseAppState { - private static final Logger log = LoggerFactory.getLogger(MusicSystem.class); - private static final float CHECK_INTERVAL = 0.25f; - private static final float FADE_DURATION = 3f; - private static final float MUSIC_VOLUME = 0.7f; + private static final Logger log = LoggerFactory.getLogger(MusicSystem.class); + private static final float CHECK_INTERVAL = 0.25f; + private static final float FADE_DURATION = 3f; + private static final float AREA_NAME_SHOW = 10f; + private static final float AREA_NAME_FADE = 1.5f; + private static final float AREA_NAME_SIZE = 24f; - private enum FadeState { INACTIVE, FADING_IN, ACTIVE, FADING_OUT } + private static final int SLOT_DAY = 0; + private static final int SLOT_NIGHT = 1; - private SimpleApplication app; - private AssetManager assets; - private Node rootNode; + private enum Phase { INACTIVE, FADING_IN, ACTIVE, FINISHING, FADING_OUT } - private final List data = new ArrayList<>(); - // three nodes per area: [0]=day, [1]=night, [2]=combat; element may be null - private final List tracks = new ArrayList<>(); - private final List fadeStates = new ArrayList<>(); + private SimpleApplication app; + private AssetManager assets; + private Node rootNode; + private AudioSettingsState audioSettings; + + private final List data = new ArrayList<>(); + private final List areaNameKeys = new ArrayList<>(); + private final List areaShortIds = new ArrayList<>(); + // index [area][slot]: slot 0 = day, slot 1 = night + private final List nodes = new ArrayList<>(); + private final List phases = new ArrayList<>(); + private final List activeSlot = new ArrayList<>(); + private final List pendingSlot = new ArrayList<>(); private Vector3f playerPos = new Vector3f(); private float checkTimer = 0f; + private boolean isDaytime = true; + + private BitmapText areaNameText; + private float areaNameTimer = 0f; + + // ── Lifecycle ───────────────────────────────────────────────────────────── @Override protected void initialize(Application application) { app = (SimpleApplication) application; assets = app.getAssetManager(); rootNode = app.getRootNode(); + audioSettings = app.getStateManager().getState(AudioSettingsState.class); + + BitmapFont font = assets.loadFont("Interface/Fonts/Default.fnt"); + areaNameText = new BitmapText(font); + areaNameText.setSize(AREA_NAME_SIZE * 2f); + areaNameText.setLocalScale(0.5f, 0.5f, 1f); + areaNameText.setColor(new ColorRGBA(1f, 0.95f, 0.8f, 0f)); + app.getGuiNode().attachChild(areaNameText); try { + Map defs = new java.util.HashMap<>(); + try { + for (AreaDefinition d : AreaDefinitionIO.load()) defs.put(d.id(), d); + } catch (IOException e) { + log.warn("[MusicSystem] Area-Definitions nicht ladbar: {}", e.getMessage()); + } for (PlacedArea area : AreaIO.load()) { - AudioNode[] arr = { - loadAmbient(area.dayTrack()), - loadAmbient(area.nightTrack()), - loadAmbient(area.combatTrack()) - }; - boolean hasAny = false; - for (AudioNode n : arr) if (n != null) { hasAny = true; break; } - if (!hasAny) continue; + AreaDefinition def = defs.get(area.areaId()); + if (def == null) continue; + AudioNode day = loadTrack(def.dayTrack()); + AudioNode night = loadTrack(def.nightTrack()); + if (day == null && night == null) continue; + String rawId = def.id(); + String shortId = rawId.startsWith("area.") ? rawId.substring("area.".length()) : rawId; data.add(area); - tracks.add(arr); - fadeStates.add(FadeState.INACTIVE); + areaNameKeys.add(def.nameKey()); + areaShortIds.add(shortId); + nodes.add(new AudioNode[]{day, night}); + phases.add(Phase.INACTIVE); + activeSlot.add(-1); + pendingSlot.add(-1); } if (!data.isEmpty()) log.info("[MusicSystem] {} Musik-Bereiche geladen.", data.size()); } catch (IOException e) { @@ -72,88 +116,212 @@ public class MusicSystem extends BaseAppState { @Override protected void cleanup(Application application) { - for (int i = 0; i < tracks.size(); i++) { - if (fadeStates.get(i) != FadeState.INACTIVE) { - for (AudioNode n : tracks.get(i)) { - if (n != null) { n.stop(); rootNode.detachChild(n); } - } - } + for (int i = 0; i < data.size(); i++) { + if (phases.get(i) != Phase.INACTIVE) stopAll(i); } data.clear(); - tracks.clear(); - fadeStates.clear(); + areaNameKeys.clear(); + areaShortIds.clear(); + nodes.clear(); + phases.clear(); + activeSlot.clear(); + pendingSlot.clear(); + if (areaNameText != null) app.getGuiNode().detachChild(areaNameText); } @Override protected void onEnable() {} @Override protected void onDisable() {} - public void setPlayerPosition(Vector3f pos) { - playerPos.set(pos); + public void setPlayerPosition(Vector3f pos) { playerPos.set(pos); } + + public void setDaytime(boolean day) { + if (isDaytime == day) return; + isDaytime = day; + // trigger track switch for all active areas + for (int i = 0; i < data.size(); i++) { + Phase ph = phases.get(i); + if (ph == Phase.INACTIVE || ph == Phase.FADING_OUT) continue; + int desired = desiredSlot(i); + if (desired != activeSlot.get(i)) { + startFinishing(i, desired); + } + } } + // ── Update ──────────────────────────────────────────────────────────────── + @Override public void update(float tpf) { - if (data.isEmpty()) return; - - for (int i = 0; i < tracks.size(); i++) { - FadeState fs = fadeStates.get(i); - if (fs == FadeState.INACTIVE || fs == FadeState.ACTIVE) continue; - - AudioNode[] arr = tracks.get(i); - if (fs == FadeState.FADING_IN) { - boolean done = true; - for (AudioNode n : arr) { - if (n == null) continue; - float nv = Math.min(n.getVolume() + MUSIC_VOLUME * tpf / FADE_DURATION, MUSIC_VOLUME); - n.setVolume(nv); - if (nv < MUSIC_VOLUME) done = false; - } - if (done) fadeStates.set(i, FadeState.ACTIVE); - } else { // FADING_OUT - boolean done = true; - for (AudioNode n : arr) { - if (n == null) continue; - float nv = Math.max(n.getVolume() - MUSIC_VOLUME * tpf / FADE_DURATION, 0f); - n.setVolume(nv); - if (nv > 0f) done = false; - } - if (done) { - for (AudioNode n : arr) { - if (n != null) { n.stop(); rootNode.detachChild(n); } - } - fadeStates.set(i, FadeState.INACTIVE); - } + // area name display timer + if (areaNameTimer > 0f) { + areaNameTimer -= tpf; + if (areaNameTimer <= 0f) { + areaNameText.setColor(new ColorRGBA(1f, 0.95f, 0.8f, 0f)); + areaNameTimer = 0f; + } else if (areaNameTimer < AREA_NAME_FADE) { + float alpha = areaNameTimer / AREA_NAME_FADE; + areaNameText.setColor(new ColorRGBA(1f, 0.95f, 0.8f, alpha)); } } + if (data.isEmpty()) return; + + // per-area fade/finish handling + live volume update for active tracks + float vol = musicVol(); + for (int i = 0; i < data.size(); i++) { + Phase ph = phases.get(i); + switch (ph) { + case FADING_IN -> tickFadeIn(i, tpf, vol); + case FINISHING -> tickFinishing(i); + case FADING_OUT -> tickFadeOut(i, tpf, vol); + case ACTIVE -> { + AudioNode n = activeNode(i); + if (n != null) n.setVolume(vol); + } + default -> {} + } + } + + // proximity check checkTimer += tpf; if (checkTimer < CHECK_INTERVAL) return; checkTimer = 0f; for (int i = 0; i < data.size(); i++) { PlacedArea area = data.get(i); - boolean inside = pointInPolygon(playerPos.x, playerPos.z, area.pointsX(), area.pointsZ()); - FadeState fs = fadeStates.get(i); + boolean inside = pointInPolygon(playerPos.x, playerPos.z, area.pointsX(), area.pointsZ()); + Phase ph = phases.get(i); - if (inside && (fs == FadeState.INACTIVE || fs == FadeState.FADING_OUT)) { - if (fs == FadeState.INACTIVE) { - log.info("[MusicSystem] Bereich {} betreten → starte Tracks", i); - for (AudioNode n : tracks.get(i)) { - if (n != null) { n.setVolume(0f); rootNode.attachChild(n); n.play(); } - } - } - fadeStates.set(i, FadeState.FADING_IN); - } else if (!inside && (fs == FadeState.ACTIVE || fs == FadeState.FADING_IN)) { - log.info("[MusicSystem] Bereich {} verlassen → fade out", i); - fadeStates.set(i, FadeState.FADING_OUT); + if (inside && (ph == Phase.INACTIVE || ph == Phase.FADING_OUT)) { + startArea(i); + } else if (!inside && (ph == Phase.ACTIVE || ph == Phase.FADING_IN || ph == Phase.FINISHING)) { + beginFadeOut(i); } } } - private AudioNode loadAmbient(String path) { + // ── State transitions ───────────────────────────────────────────────────── + + private void startArea(int i) { + int slot = desiredSlot(i); + AudioNode n = nodes.get(i)[slot]; + if (n == null) return; + n.setVolume(0f); + n.setLooping(true); + rootNode.attachChild(n); + n.play(); + activeSlot.set(i, slot); + pendingSlot.set(i, -1); + phases.set(i, Phase.FADING_IN); + log.info("[MusicSystem] Area {} → slot {} gestartet", i, slot == SLOT_DAY ? "Tag" : "Nacht"); + showAreaName(i); + } + + private void startFinishing(int i, int desired) { + // let current track play to end, then switch + AudioNode cur = activeNode(i); + if (cur != null) cur.setLooping(false); + pendingSlot.set(i, desired); + phases.set(i, Phase.FINISHING); + log.info("[MusicSystem] Area {} → Slot wechsel nach {}, warte auf Loop-Ende", i, desired == SLOT_DAY ? "Tag" : "Nacht"); + } + + private void beginFadeOut(int i) { + phases.set(i, Phase.FADING_OUT); + } + + private void stopAll(int i) { + for (AudioNode n : nodes.get(i)) { + if (n != null && n.getParent() != null) { n.stop(); rootNode.detachChild(n); } + } + activeSlot.set(i, -1); + pendingSlot.set(i, -1); + phases.set(i, Phase.INACTIVE); + } + + // ── Tick helpers ────────────────────────────────────────────────────────── + + private void tickFadeIn(int i, float tpf, float vol) { + AudioNode n = activeNode(i); + if (n == null) { phases.set(i, Phase.ACTIVE); return; } + float nv = Math.min(n.getVolume() + vol * tpf / FADE_DURATION, vol); + n.setVolume(nv); + if (nv >= vol) phases.set(i, Phase.ACTIVE); + } + + private void tickFinishing(int i) { + AudioNode cur = activeNode(i); + // wait until the current (non-looping) track finishes + if (cur != null && cur.getStatus() != AudioSource.Status.Stopped) return; + + // detach old + if (cur != null && cur.getParent() != null) { cur.stop(); rootNode.detachChild(cur); } + activeSlot.set(i, -1); + + int next = pendingSlot.get(i); + pendingSlot.set(i, -1); + if (next < 0 || nodes.get(i)[next] == null) { + // fallback to day + next = SLOT_DAY; + } + AudioNode n = nodes.get(i)[next]; + if (n == null) { phases.set(i, Phase.INACTIVE); return; } + n.setVolume(0f); + n.setLooping(true); + rootNode.attachChild(n); + n.play(); + activeSlot.set(i, next); + phases.set(i, Phase.FADING_IN); + log.info("[MusicSystem] Area {} → nächster Slot {} gestartet", i, next == SLOT_DAY ? "Tag" : "Nacht"); + } + + private void tickFadeOut(int i, float tpf, float vol) { + AudioNode n = activeNode(i); + if (n == null) { stopAll(i); return; } + float nv = Math.max(n.getVolume() - vol * tpf / FADE_DURATION, 0f); + n.setVolume(nv); + if (nv <= 0f) stopAll(i); + } + + // ── Area-Name-Anzeige ───────────────────────────────────────────────────── + + private void showAreaName(int i) { + String key = i < areaNameKeys.size() ? areaNameKeys.get(i) : ""; + String shortId = i < areaShortIds.size() ? areaShortIds.get(i) : key; + String resolved = TextResolver.get().resolveOrId(key); + String name = resolved.equals(key) ? shortId : resolved; + if (name.isBlank()) return; + + areaNameText.setText(name); + float tw = areaNameText.getLineWidth() * 0.5f; + float sw = app.getCamera().getWidth(); + float sh = app.getCamera().getHeight(); + areaNameText.setLocalTranslation((sw - tw) / 2f, sh - 40f, 0f); + areaNameText.setColor(new ColorRGBA(1f, 0.95f, 0.8f, 1f)); + areaNameTimer = AREA_NAME_SHOW; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private float musicVol() { + return audioSettings != null ? audioSettings.effectiveMusic() : 0.7f; + } + + private int desiredSlot(int i) { + AudioNode[] arr = nodes.get(i); + if (!isDaytime && arr[SLOT_NIGHT] != null) return SLOT_NIGHT; + return SLOT_DAY; + } + + private AudioNode activeNode(int i) { + int slot = activeSlot.get(i); + if (slot < 0) return null; + return nodes.get(i)[slot]; + } + + private AudioNode loadTrack(String path) { if (path == null || path.isEmpty()) return null; try { - // Use Stream for music (files are large), Buffer for short sfx AudioNode n = new AudioNode(assets, path, AudioData.DataType.Stream); n.setLooping(true); n.setPositional(false); diff --git a/blight-lang/src/main/java/de/blight/lang/TextResolver.java b/blight-lang/src/main/java/de/blight/lang/TextResolver.java index 6967ebd..50e8677 100644 --- a/blight-lang/src/main/java/de/blight/lang/TextResolver.java +++ b/blight-lang/src/main/java/de/blight/lang/TextResolver.java @@ -68,4 +68,23 @@ public class TextResolver { } } } + + /** Wie resolveId(), gibt aber bei fehlendem Key den Key selbst zurück statt [key]. */ + public String resolveOrId(String id) { + if (id == null || id.isBlank()) return ""; + try { + return bundle.getString(id); + } catch (MissingResourceException e) { + try { + return fallback.getString(id); + } catch (MissingResourceException ex) { + return id; + } + } + } + + public String resolve(TextReference ref, String fallbackText) { + if (ref == null || ref.id() == null || ref.id().isBlank()) return fallbackText; + return resolveOrId(ref.id()).equals(ref.id()) ? fallbackText : resolveOrId(ref.id()); + } } diff --git a/blight-lang/src/main/resources/lang/text_keys.txt b/blight-lang/src/main/resources/lang/text_keys.txt index 26df71b..90591d8 100644 --- a/blight-lang/src/main/resources/lang/text_keys.txt +++ b/blight-lang/src/main/resources/lang/text_keys.txt @@ -93,4 +93,7 @@ dialog.silas.wer_seid_ihr.textnpc erzmoss.description erzmoss.name hero.name +location.neu_1786533928723 +location.neu_1786546178407 +location.test silas.name diff --git a/blight-map/src/main/map/blight_area_definitions.bad b/blight-map/src/main/map/blight_area_definitions.bad new file mode 100644 index 0000000..4d643d2 --- /dev/null +++ b/blight-map/src/main/map/blight_area_definitions.bad @@ -0,0 +1,2 @@ +# id dayTrack nightTrack combatTrack +area.test audio/music/04-The Island.ogg audio/music/05-The Island At Night.ogg diff --git a/blight-map/src/main/map/blight_areas.bla b/blight-map/src/main/map/blight_areas.bla index 40997eb..8fad02b 100644 --- a/blight-map/src/main/map/blight_areas.bla +++ b/blight-map/src/main/map/blight_areas.bla @@ -1 +1,2 @@ -# polygon nameId dayTrack nightTrack combatTrack +# polygon areaId +99.610,-1029.072;108.552,-1049.218;144.375,-1062.661;138.386,-1033.647;126.795,-1015.806;107.068,-1012.408 area.test diff --git a/blight-map/src/main/map/blight_location_zones.blz b/blight-map/src/main/map/blight_location_zones.blz index a7e9071..96292ad 100644 --- a/blight-map/src/main/map/blight_location_zones.blz +++ b/blight-map/src/main/map/blight_location_zones.blz @@ -1 +1,2 @@ # polygon nameId triggersJson +146.483,-1176.914;146.483,-1176.914;164.264,-1150.639;164.264,-1150.639;153.757,-1084.363;153.757,-1084.363;136.373,-1067.334;136.373,-1067.334;132.929,-1079.042;132.929,-1079.042;139.551,-1105.392;139.551,-1105.392;136.457,-1154.189;136.457,-1154.189 [] diff --git a/blight-map/src/main/map/blight_sound_areas.bsa b/blight-map/src/main/map/blight_sound_areas.bsa index 40258fa..a26d2c5 100644 --- a/blight-map/src/main/map/blight_sound_areas.bsa +++ b/blight-map/src/main/map/blight_sound_areas.bsa @@ -1 +1,3 @@ # polygon soundPath volume crossfade +95.987,-1142.755;95.987,-1142.755;86.561,-1120.652;86.561,-1120.652;96.797,-1051.660;96.797,-1051.660;117.239,-1060.129;117.239,-1060.129;123.290,-1097.722;123.290,-1097.722 1.0000 false +134.038,-1118.262;134.038,-1118.262;109.293,-1093.472;109.293,-1093.472;117.239,-1060.129;117.239,-1060.129;137.925,-1072.836;137.925,-1072.836 1.0000 false