Commit vor großem Terrain refactoring

This commit is contained in:
2026-06-08 08:42:45 +02:00
parent 7faed35287
commit 1297869dfa
119 changed files with 9784 additions and 1614 deletions

View File

@@ -0,0 +1,53 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
public final class AreaIO {
private AreaIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_areas.bla");
}
public static void save(List<PlacedArea> areas) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# polygon\tnameId\tdayTrack\tnightTrack\tcombatTrack");
w.newLine();
for (PlacedArea a : areas) {
w.write(SoundAreaIO.encodePolygon(a.pointsX(), a.pointsZ()));
w.write('\t');
w.write(a.nameId());
w.write('\t');
w.write(a.dayTrack());
w.write('\t');
w.write(a.nightTrack());
w.write('\t');
w.write(a.combatTrack());
w.newLine();
}
}
}
public static List<PlacedArea> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedArea> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 5) 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]));
} catch (Exception ignored) {}
}
return list;
}
}

View File

@@ -0,0 +1,4 @@
package de.blight.common;
/** Einzeln platzierter Vertex-Gras-Halm. Y-Position wird beim Platzieren aus dem Terrain gebacken. */
public record GrassVertexBlade(float x, float y, float z, float height, float dryness) {}

View File

@@ -0,0 +1,66 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
/**
* Liest und schreibt Vertex-Gras-Halme als komprimierte Binärdatei
* ({@code blight_grass_vertex.blgv}) neben der Kartendatei.
*
* Format v1: int MAGIC, int VERSION, int count, N × (float x, float y, float z, float height)
* Format v2: wie v1 + float dryness pro Halm (0=grün, 0.51.0=gelb/braun)
*/
public final class GrassVertexIO {
private static final int MAGIC = 0x47565458; // "GVTX"
private static final int VERSION = 2;
private GrassVertexIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_grass_vertex.blgv");
}
public static void save(List<GrassVertexBlade> blades) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new GZIPOutputStream(Files.newOutputStream(p))))) {
out.writeInt(MAGIC);
out.writeInt(VERSION);
out.writeInt(blades.size());
for (GrassVertexBlade b : blades) {
out.writeFloat(b.x());
out.writeFloat(b.y());
out.writeFloat(b.z());
out.writeFloat(b.height());
out.writeFloat(b.dryness());
}
}
}
public static List<GrassVertexBlade> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new GZIPInputStream(Files.newInputStream(p))))) {
int magic = in.readInt();
if (magic != MAGIC) throw new IOException("Ungültiges Dateiformat (MAGIC)");
int version = in.readInt();
if (version < 1 || version > VERSION) throw new IOException("Unbekannte Version: " + version);
int count = in.readInt();
List<GrassVertexBlade> list = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
float x = in.readFloat();
float y = in.readFloat();
float z = in.readFloat();
float h = in.readFloat();
float dr = version >= 2 ? in.readFloat() : 0f;
list.add(new GrassVertexBlade(x, y, z, h, dr));
}
return list;
}
}
}

View File

@@ -0,0 +1,70 @@
package de.blight.common;
import de.blight.common.model.Location;
import de.blight.common.model.TextReference;
import de.blight.common.model.trigger.Trigger;
import de.blight.common.model.trigger.TriggerIO;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Liest und schreibt Locations ({@code blight_locations.blo}) neben der Kartendatei.
*
* Format (je Zeile): nameId TAB centerX TAB centerZ TAB radius TAB triggersJson
*/
public final class LocationIO {
private LocationIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_locations.blo");
}
public static void save(List<Location> locations) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# nameId\tcenterX\tcenterZ\tradius\ttriggersJson");
w.newLine();
for (Location loc : locations) {
w.write(loc.getId());
w.write('\t');
w.write(String.format(Locale.ROOT, "%.3f", loc.getCenterX()));
w.write('\t');
w.write(String.format(Locale.ROOT, "%.3f", loc.getCenterZ()));
w.write('\t');
w.write(String.format(Locale.ROOT, "%.3f", loc.getRadius()));
w.write('\t');
w.write(TriggerIO.serializeList(loc.getTriggers()));
w.newLine();
}
}
}
public static List<Location> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<Location> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 4) continue;
try {
Location loc = new Location();
loc.setName(new TextReference(f[0].strip()));
loc.setCenterX(Float.parseFloat(f[1].strip()));
loc.setCenterZ(Float.parseFloat(f[2].strip()));
loc.setRadius(Float.parseFloat(f[3].strip()));
if (f.length > 4) {
List<Trigger> triggers = TriggerIO.deserializeList(f[4].strip());
loc.setTriggers(triggers);
}
list.add(loc);
} catch (NumberFormatException ignored) {}
}
return list;
}
}

View File

@@ -0,0 +1,53 @@
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.*;
public final class LocationZoneIO {
private LocationZoneIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_location_zones.blz");
}
public static void save(List<PlacedLocationZone> zones) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# polygon\tnameId\ttriggersJson");
w.newLine();
for (PlacedLocationZone z : zones) {
w.write(SoundAreaIO.encodePolygon(z.pointsX(), z.pointsZ()));
w.write('\t');
w.write(z.nameId());
w.write('\t');
w.write(TriggerIO.serializeList(z.triggers()));
w.newLine();
}
}
}
public static List<PlacedLocationZone> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedLocationZone> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 2) continue;
try {
float[][] pts = SoundAreaIO.decodePolygon(f[0]);
if (pts[0].length < 3) continue;
List<Trigger> triggers = f.length > 2 ? TriggerIO.deserializeList(f[2]) : new ArrayList<>();
list.add(new PlacedLocationZone(pts[0], pts[1], f[1], triggers));
} catch (Exception ignored) {}
}
return list;
}
}

View File

@@ -88,9 +88,10 @@ public final class MapIO {
public static void save(MapData data) throws IOException {
Files.createDirectories(MAP_PATH.getParent());
Path tmp = MAP_PATH.resolveSibling(MAP_PATH.getFileName() + ".tmp");
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(
new GZIPOutputStream(Files.newOutputStream(MAP_PATH))))) {
new GZIPOutputStream(Files.newOutputStream(tmp))))) {
out.writeInt(MAGIC);
out.writeInt(VERSION);
writeFloats(out, data.terrainHeight);
@@ -127,6 +128,14 @@ public final class MapIO {
for (int i = 0; i < slotEnd; i++) out.writeUTF(slots[i] != null ? slots[i] : "");
out.write(data.grassTextureMap);
}
// Atomares Umbenennen: erst wenn die Datei vollständig ist ersetzen wir die alte.
// ATOMIC_MOVE schlägt auf manchen Systemen cross-device fehl → Fallback auf REPLACE_EXISTING.
try {
Files.move(tmp, MAP_PATH, java.nio.file.StandardCopyOption.REPLACE_EXISTING,
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
} catch (java.nio.file.AtomicMoveNotSupportedException e) {
Files.move(tmp, MAP_PATH, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
public static MapData load() throws IOException {
@@ -243,17 +252,32 @@ public final class MapIO {
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static final int FLOAT_CHUNK = 16384; // floats per I/O chunk (64 KB)
private static void writeFloats(DataOutputStream out, float[] arr) throws IOException {
ByteBuffer buf = ByteBuffer.allocate(arr.length * Float.BYTES)
.order(ByteOrder.BIG_ENDIAN);
buf.asFloatBuffer().put(arr);
out.write(buf.array());
byte[] bytes = new byte[FLOAT_CHUNK * Float.BYTES];
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
int offset = 0;
while (offset < arr.length) {
int count = Math.min(FLOAT_CHUNK, arr.length - offset);
bb.clear();
for (int i = 0; i < count; i++) bb.putFloat(arr[offset + i]);
out.write(bytes, 0, count * Float.BYTES);
offset += count;
}
}
private static void readFloats(DataInputStream in, float[] arr) throws IOException {
byte[] bytes = new byte[arr.length * Float.BYTES];
in.readFully(bytes);
ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).asFloatBuffer().get(arr);
byte[] bytes = new byte[FLOAT_CHUNK * Float.BYTES];
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
int offset = 0;
while (offset < arr.length) {
int count = Math.min(FLOAT_CHUNK, arr.length - offset);
in.readFully(bytes, 0, count * Float.BYTES);
bb.clear();
bb.asFloatBuffer().get(arr, offset, count);
offset += count;
}
}
private static void writeStrings(DataOutputStream out, String[] arr) throws IOException {

View File

@@ -0,0 +1,28 @@
package de.blight.common;
/**
* Metadaten einer Model-Asset-Datei (.j3o.meta neben dem .j3o).
* Skalierung ist per-Achse; uniformScale=true → X/Y/Z werden gemeinsam angepasst.
*/
public record ModelMeta(
String name,
String category,
String tags,
float scaleX,
float scaleY,
float scaleZ,
boolean uniformScale,
float pivotOffsetY,
float placementOffsetY,
boolean solid,
boolean castShadow,
boolean receiveShadow,
float randomScaleMin,
float randomScaleMax
) {
public static ModelMeta defaults(String j3oFileName) {
String name = j3oFileName.replaceFirst("\\.j3o$", "");
return new ModelMeta(name, "", "", 1f, 1f, 1f, true, 0f, 0f,
false, true, true, 1f, 1f);
}
}

View File

@@ -0,0 +1,68 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.Properties;
/** Liest und schreibt {@code .j3o.meta}-Dateien neben dem jeweiligen {@code .j3o}. */
public final class ModelMetaIO {
private ModelMetaIO() {}
public static Path metaPath(Path j3oPath) {
return j3oPath.resolveSibling(j3oPath.getFileName() + ".meta");
}
public static void save(ModelMeta m, Path j3oPath) throws IOException {
Properties p = new Properties();
p.setProperty("name", m.name());
p.setProperty("category", m.category());
p.setProperty("tags", m.tags());
p.setProperty("scaleX", String.valueOf(m.scaleX()));
p.setProperty("scaleY", String.valueOf(m.scaleY()));
p.setProperty("scaleZ", String.valueOf(m.scaleZ()));
p.setProperty("uniformScale", String.valueOf(m.uniformScale()));
p.setProperty("pivotOffsetY", String.valueOf(m.pivotOffsetY()));
p.setProperty("placementOffsetY", String.valueOf(m.placementOffsetY()));
p.setProperty("solid", String.valueOf(m.solid()));
p.setProperty("castShadow", String.valueOf(m.castShadow()));
p.setProperty("receiveShadow", String.valueOf(m.receiveShadow()));
p.setProperty("randomScaleMin", String.valueOf(m.randomScaleMin()));
p.setProperty("randomScaleMax", String.valueOf(m.randomScaleMax()));
try (Writer w = Files.newBufferedWriter(metaPath(j3oPath))) {
p.store(w, null);
}
}
public static ModelMeta load(Path j3oPath) {
Path mp = metaPath(j3oPath);
Properties p = new Properties();
if (Files.exists(mp)) {
try (Reader r = Files.newBufferedReader(mp)) {
p.load(r);
} catch (IOException ignored) {}
}
String defName = j3oPath.getFileName().toString().replaceFirst("\\.j3o$", "");
return new ModelMeta(
p.getProperty("name", defName),
p.getProperty("category", ""),
p.getProperty("tags", ""),
parseFloat(p, "scaleX", 1f),
parseFloat(p, "scaleY", 1f),
parseFloat(p, "scaleZ", 1f),
Boolean.parseBoolean(p.getProperty("uniformScale", "true")),
parseFloat(p, "pivotOffsetY", 0f),
parseFloat(p, "placementOffsetY", 0f),
Boolean.parseBoolean(p.getProperty("solid", "false")),
Boolean.parseBoolean(p.getProperty("castShadow", "true")),
Boolean.parseBoolean(p.getProperty("receiveShadow", "true")),
parseFloat(p, "randomScaleMin", 1f),
parseFloat(p, "randomScaleMax", 1f)
);
}
private static float parseFloat(Properties p, String key, float def) {
try { return Float.parseFloat(p.getProperty(key, String.valueOf(def))); }
catch (NumberFormatException e) { return def; }
}
}

View File

@@ -0,0 +1,10 @@
package de.blight.common;
public record PlacedArea(
float[] pointsX,
float[] pointsZ,
String nameId,
String dayTrack,
String nightTrack,
String combatTrack
) {}

View File

@@ -0,0 +1,17 @@
package de.blight.common;
import de.blight.common.model.trigger.Trigger;
import java.util.List;
public record PlacedLocationZone(
float[] pointsX,
float[] pointsZ,
String nameId,
List<Trigger> triggers
) {
/** Rückwärtskompatibel: kein Trigger. */
public PlacedLocationZone(float[] pointsX, float[] pointsZ, String nameId) {
this(pointsX, pointsZ, nameId, List.of());
}
}

View File

@@ -1,8 +1,6 @@
package de.blight.common;
/**
* Platzierte Wasserfläche.
* Die Form wird zur Laufzeit per Flood-Fill aus dem Geländenetz berechnet
* gespeichert werden nur Saatpunkt und Wasserstand.
* Platzierte Wasserfläche, definiert durch ein Benutzer-Polygon, eine Höhe und eine Fließrichtung.
*/
public record PlacedWater(float seedX, float seedZ, float waterHeight) {}
public record PlacedWater(float[] pointsX, float[] pointsZ, float waterHeight, float flowDegrees) {}

View File

@@ -5,11 +5,9 @@ import java.nio.file.*;
import java.util.*;
/**
* Liest und schreibt platzierte Wasserflächen als tab-separierte Textdatei
* ({@code blight_water.blw}) neben der Kartendatei.
* Liest und schreibt platzierte Wasserflächen ({@code blight_water.blw}) neben der Kartendatei.
*
* Format: seedX seedZ waterHeight
* Die Form des Wasserkörpers wird per Flood-Fill zur Laufzeit rekonstruiert.
* Format (je Zeile): x1,z1;x2,z2;x3,z3;... TAB waterHeight [TAB flowDegrees]
*/
public final class WaterBodyIO {
@@ -23,11 +21,20 @@ public final class WaterBodyIO {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# seedX\tseedZ\twaterHeight");
w.write("# polygon_points\twaterHeight\tflowDegrees");
w.newLine();
for (PlacedWater b : bodies) {
w.write(String.format(Locale.ROOT, "%.5f\t%.5f\t%.5f%n",
b.seedX(), b.seedZ(), b.waterHeight()));
StringBuilder sb = new StringBuilder();
for (int i = 0; i < b.pointsX().length; i++) {
if (i > 0) sb.append(';');
sb.append(String.format(Locale.ROOT, "%.5f,%.5f", b.pointsX()[i], b.pointsZ()[i]));
}
sb.append('\t');
sb.append(String.format(Locale.ROOT, "%.5f", b.waterHeight()));
sb.append('\t');
sb.append(String.format(Locale.ROOT, "%.1f", b.flowDegrees()));
w.write(sb.toString());
w.newLine();
}
}
}
@@ -39,14 +46,21 @@ public final class WaterBodyIO {
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 3) continue;
String[] parts = line.split("\t", -1);
if (parts.length < 2) continue;
try {
list.add(new PlacedWater(
Float.parseFloat(f[0]),
Float.parseFloat(f[1]),
Float.parseFloat(f[2])));
} catch (NumberFormatException ignored) {}
float wh = Float.parseFloat(parts[1].strip());
float fd = parts.length >= 3 ? Float.parseFloat(parts[2].strip()) : 0f;
String[] pts = parts[0].split(";", -1);
float[] xs = new float[pts.length];
float[] zs = new float[pts.length];
for (int i = 0; i < pts.length; i++) {
String[] c = pts[i].split(",", -1);
xs[i] = Float.parseFloat(c[0].strip());
zs[i] = Float.parseFloat(c[1].strip());
}
if (xs.length >= 3) list.add(new PlacedWater(xs, zs, wh, fd));
} catch (NumberFormatException | ArrayIndexOutOfBoundsException ignored) {}
}
return list;
}

View File

@@ -1,12 +0,0 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Collectable implements Interactable {
private Item item;
private ObjectReference objectReference;
}

View File

@@ -16,4 +16,14 @@ public class CraftingTable {
private TextReference name;
private ObjectReference object;
private CraftingTableType type;
public enum CraftingTableType {
AlchemyTable,
EnchantmentTable,
Smithy,
Goldsmiths,
Workshop;
}
}

View File

@@ -0,0 +1,92 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
/**
* Speichert genau eine {@link CraftingTable}-Instanz pro {@link CraftingTable.CraftingTableType}
* als {@code <TypeName>.craftingtable}-JSON-Datei.
*/
public final class CraftingTableIO {
private static final Logger log = LoggerFactory.getLogger(CraftingTableIO.class);
private static final String EXTENSION = ".craftingtable";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private CraftingTableIO() {}
public static void save(CraftingTable table, Path dir) throws IOException {
if (table.getType() == null) throw new IOException("CraftingTable ohne Typ kann nicht gespeichert werden.");
Files.createDirectories(dir);
Files.writeString(dir.resolve(table.getType().name() + EXTENSION),
GSON.toJson(toDto(table)), StandardCharsets.UTF_8);
log.debug("[CraftingTableIO] Gespeichert: {}", table.getType().name());
}
public static Optional<CraftingTable> load(CraftingTable.CraftingTableType type, Path dir) {
Path file = dir.resolve(type.name() + EXTENSION);
if (!Files.exists(file)) return Optional.empty();
try {
Dto dto = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), Dto.class);
return Optional.of(fromDto(dto, type));
} catch (IOException e) {
log.warn("[CraftingTableIO] Fehler beim Laden von {}: {}", type.name(), e.getMessage());
return Optional.empty();
}
}
/**
* Lädt alle konfigurierten Tische. Nicht vorhandene Typen fehlen in der Map.
*/
public static Map<CraftingTable.CraftingTableType, CraftingTable> loadAll(Path dir) {
Map<CraftingTable.CraftingTableType, CraftingTable> result =
new EnumMap<>(CraftingTable.CraftingTableType.class);
for (CraftingTable.CraftingTableType type : CraftingTable.CraftingTableType.values())
load(type, dir).ifPresent(t -> result.put(type, t));
return result;
}
public static void delete(CraftingTable.CraftingTableType type, Path dir) throws IOException {
Files.deleteIfExists(dir.resolve(type.name() + EXTENSION));
log.debug("[CraftingTableIO] Gelöscht: {}", type.name());
}
// ── DTO ───────────────────────────────────────────────────────────────────
private static Dto toDto(CraftingTable t) {
Dto dto = new Dto();
dto.type = t.getType() != null ? t.getType().name() : null;
dto.nameId = t.getName() != null ? t.getName().id() : null;
dto.objectPath = t.getObject() != null ? t.getObject().getPath() : null;
return dto;
}
private static CraftingTable fromDto(Dto dto, CraftingTable.CraftingTableType fallbackType) {
CraftingTable t = new CraftingTable();
if (dto.type != null) {
try { t.setType(CraftingTable.CraftingTableType.valueOf(dto.type)); }
catch (IllegalArgumentException ignored) { t.setType(fallbackType); }
} else {
t.setType(fallbackType);
}
if (dto.nameId != null && !dto.nameId.isBlank())
t.setName(new TextReference(dto.nameId));
if (dto.objectPath != null && !dto.objectPath.isBlank())
t.setObject(new ObjectReference(dto.objectPath));
return t;
}
private static class Dto {
String type;
String nameId;
String objectPath;
}
}

View File

@@ -2,8 +2,8 @@ package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import de.blight.common.model.quests.Quest;
import lombok.Getter;
import lombok.Setter;
@@ -11,25 +11,28 @@ import lombok.Setter;
@Setter
public class DialogOption {
private String id = UUID.randomUUID().toString();
private String label = "";
private int requiresChapter;
private Quest requiresQuestOpen;
private Quest requiresQuestComplete;
private QuestRef requiresQuestOpen;
private QuestRef requiresQuestComplete;
private Status requiresStatus;
private TextReference textHero;
private AudioReference audioHero;
private TextReference textNpc;
private AudioReference audioNpc;
private List<DialogOption> nextOptions;
private List<DialogOption> disablesOptions;
private RequiredItem requiredItem;
private RecievesItem recievesItem;
private Quest recievesQuest;
private Quest fulfillsQuest;
private List<Quest> abortsQuests = new ArrayList<Quest>();
private QuestRef recievesQuest;
private QuestRef fulfillsQuest;
private List<QuestRef> abortsQuests = new ArrayList<>();
private boolean enablesTrade;

View File

@@ -0,0 +1,19 @@
package de.blight.common.model;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Fraction {
private UUID fractionId;
private TextReference name;
private TextReference maleMemberName;
private TextReference femaleMemberName;
private TextReference rank1Name;
private TextReference rank2Name;
private TextReference rank3Name;
}

View File

@@ -0,0 +1,109 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
/**
* Lädt und speichert {@link Fraction}-Instanzen als JSON.
* Dateiformat: {@code <fractionId>.fraction} im fractions/-Verzeichnis.
*/
public final class FractionIO {
private static final Logger log = LoggerFactory.getLogger(FractionIO.class);
private static final String EXTENSION = ".fraction";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
public static final Comparator<Fraction> SORT_ORDER = Comparator
.comparing((Fraction f) -> f.getName() != null ? f.getName().id() : "")
.thenComparing(f -> f.getFractionId() != null ? f.getFractionId().toString() : "");
private FractionIO() {}
public static void save(Fraction fraction, Path dir) throws IOException {
if (fraction.getFractionId() == null)
throw new IOException("Fraction ohne ID kann nicht gespeichert werden.");
Files.createDirectories(dir);
Files.writeString(dir.resolve(fraction.getFractionId() + EXTENSION),
GSON.toJson(toDto(fraction)), StandardCharsets.UTF_8);
log.debug("[FractionIO] Gespeichert: {}", fraction.getFractionId());
}
public static Fraction load(Path file) throws IOException {
Dto dto = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), Dto.class);
return fromDto(dto);
}
public static List<Fraction> loadAll(Path dir) {
List<Fraction> result = new ArrayList<>();
if (!Files.isDirectory(dir)) return result;
try (Stream<Path> walk = Files.list(dir)) {
walk.filter(p -> p.toString().endsWith(EXTENSION))
.sorted()
.forEach(p -> {
try { result.add(load(p)); }
catch (IOException e) { log.warn("[FractionIO] Fehler: {}", e.getMessage()); }
});
} catch (IOException e) {
log.warn("[FractionIO] Scan-Fehler: {}", e.getMessage());
}
result.sort(SORT_ORDER);
return result;
}
public static void delete(UUID fractionId, Path dir) throws IOException {
if (fractionId != null)
Files.deleteIfExists(dir.resolve(fractionId + EXTENSION));
}
// ── DTO ───────────────────────────────────────────────────────────────────
private static Dto toDto(Fraction f) {
Dto dto = new Dto();
dto.fractionId = f.getFractionId() != null ? f.getFractionId().toString() : null;
dto.name = f.getName() != null ? f.getName().id() : null;
dto.maleMemberName = f.getMaleMemberName() != null ? f.getMaleMemberName().id() : null;
dto.femaleMemberName = f.getFemaleMemberName() != null ? f.getFemaleMemberName().id() : null;
dto.rank1Name = f.getRank1Name() != null ? f.getRank1Name().id() : null;
dto.rank2Name = f.getRank2Name() != null ? f.getRank2Name().id() : null;
dto.rank3Name = f.getRank3Name() != null ? f.getRank3Name().id() : null;
return dto;
}
private static Fraction fromDto(Dto dto) {
Fraction f = new Fraction();
if (dto.fractionId != null) {
try { f.setFractionId(UUID.fromString(dto.fractionId)); }
catch (IllegalArgumentException ignored) {}
}
f.setName(ref(dto.name));
f.setMaleMemberName(ref(dto.maleMemberName));
f.setFemaleMemberName(ref(dto.femaleMemberName));
f.setRank1Name(ref(dto.rank1Name));
f.setRank2Name(ref(dto.rank2Name));
f.setRank3Name(ref(dto.rank3Name));
return f;
}
private static TextReference ref(String id) {
return (id != null && !id.isBlank()) ? new TextReference(id) : null;
}
private static class Dto {
String fractionId;
String name;
String maleMemberName;
String femaleMemberName;
String rank1Name;
String rank2Name;
String rank3Name;
}
}

View File

@@ -2,4 +2,5 @@ package de.blight.common.model;
public interface Interactable {
public String getDisplayText();
}

View File

@@ -0,0 +1,18 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
/**
* Platzhalter-Implementierung von Interactable mit einer ID für den Editor.
*/
@Getter
@Setter
public class InteractableRef implements Interactable {
private String id;
@Override
public String getDisplayText() {
return id != null ? id : "";
}
}

View File

@@ -5,16 +5,21 @@ import lombok.Setter;
@Getter
@Setter
public class Item {
public class Item implements Interactable {
private String itemId;
private ItemCategory category;
private TextReference name;
private TextReference description;
private int worthGold;
private ObjectReference modelRef;
public void use(MainCharacter character) {
}
@Override
public String getDisplayText() {
return TextRegistry.resolve(name, itemId != null ? itemId : "?");
}
}

View File

@@ -0,0 +1,69 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
/**
* Lädt und speichert {@link Item}-Instanzen als JSON.
* Dateiformat: {@code <itemId>.item} im items/-Verzeichnis.
* Liste wird nach Kategorie-Ordinal, dann nach Name (TextReference-ID) sortiert.
*/
public final class ItemIO {
private static final Logger log = LoggerFactory.getLogger(ItemIO.class);
private static final String EXTENSION = ".item";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
public static final Comparator<Item> SORT_ORDER = Comparator
.comparingInt((Item i) -> i.getCategory() != null ? i.getCategory().ordinal() : Integer.MAX_VALUE)
.thenComparing(i -> i.getName() != null ? i.getName().id() : (i.getItemId() != null ? i.getItemId() : ""));
private ItemIO() {}
// ── Public API ────────────────────────────────────────────────────────────
public static void save(Item item, Path itemDir) throws IOException {
if (item.getItemId() == null || item.getItemId().isBlank())
throw new IllegalArgumentException("itemId darf nicht leer sein");
Files.createDirectories(itemDir);
Files.writeString(itemDir.resolve(item.getItemId() + EXTENSION),
GSON.toJson(item), StandardCharsets.UTF_8);
log.debug("[ItemIO] Gespeichert: {}", item.getItemId());
}
public static Item load(Path file) throws IOException {
return GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), Item.class);
}
public static List<Item> loadAll(Path itemDir) {
List<Item> result = new ArrayList<>();
if (!Files.isDirectory(itemDir)) return result;
try (Stream<Path> walk = Files.list(itemDir)) {
walk.filter(p -> p.toString().endsWith(EXTENSION))
.sorted()
.forEach(p -> {
try { result.add(load(p)); }
catch (IOException e) { log.warn("[ItemIO] Fehler: {}", e.getMessage()); }
});
} catch (IOException e) {
log.warn("[ItemIO] Scan-Fehler: {}", e.getMessage());
}
result.sort(SORT_ORDER);
return result;
}
public static void delete(String itemId, Path itemDir) throws IOException {
Files.deleteIfExists(itemDir.resolve(itemId + EXTENSION));
}
}

View File

@@ -1,5 +1,33 @@
package de.blight.common.model;
public interface Location {
import java.util.ArrayList;
import java.util.List;
import de.blight.common.model.trigger.Trigger;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Location {
private TextReference name;
private float centerX;
private float centerZ;
private float radius;
private List<Trigger> triggers = new ArrayList<>();
/** Leitet die ID aus dem TextReference-Schlüssel ab eindeutiger Bezeichner der Location. */
public String getId() { return name != null ? name.id() : ""; }
public boolean contains(float x, float z) {
float dx = x - centerX, dz = z - centerZ;
return dx * dx + dz * dz <= radius * radius;
}
public void entered(MainCharacter character) {
triggers.stream()
.filter(t -> t.isTriggarable(character))
.forEach(t -> t.trigger(character));
}
}

View File

@@ -28,6 +28,9 @@ public class MainCharacter extends GameCharacter {
private List<Quest> openQuests;
private List<Quest> completedQuests;
private List<Quest> abortedQuests;
private de.blight.common.model.abilities.Abilities abilities;
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
@@ -46,6 +49,10 @@ public class MainCharacter extends GameCharacter {
option.getAbortsQuests().forEach(this::abortQuest);
}
public void startQuest(Quest quest) {
if (isQuestNew(quest)) openQuests.add(quest);
}
public void fullfillQuest(Quest quest) {
openQuests.remove(quest);
completedQuests.add(quest);
@@ -65,9 +72,14 @@ public class MainCharacter extends GameCharacter {
private void abortQuest(Quest quest) {
openQuests.remove(quest);
abortedQuests.add(quest);
listeners.forEach(listener -> listener.questAborted(quest));
}
public boolean isQuestNew(Quest quest) {
return !openQuests.contains(quest) && !completedQuests.contains(quest) && !abortedQuests.contains(quest);
}
public void removeListener(CharacterListener listener) {
listeners.remove(listener);
}

View File

@@ -13,9 +13,11 @@ import lombok.Setter;
public class NPC extends GameCharacter {
private static final Logger LOG = LoggerFactory.getLogger(NPC.class);
private Status status;
private boolean trader;
private Fraction fraction;
private List<Item> items;
private List<DialogOption> currentOptions;

View File

@@ -1,5 +1,19 @@
package de.blight.common.model;
public interface ObjectReference {
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Referenz auf ein 3D-Asset (z. B. ein .j3o-Modell).
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ObjectReference {
/** Relativer Asset-Pfad (z. B. {@code Models/Items/sword.j3o}). */
private String path;
}

View File

@@ -0,0 +1,12 @@
package de.blight.common.model;
import de.blight.common.model.quests.Quest;
/**
* Minimale Quest-Referenz (nur questId) für Verweise in DialogOption.
* Kein vollständiger Quest-Datensatz.
*/
public class QuestRef extends Quest {
// Nur die gemeinsamen Felder von Quest (questId, xp, texte) werden verwendet.
// Typspezifische Felder sind nicht vorhanden.
}

View File

@@ -11,5 +11,10 @@ public class Recipe {
private Item creates;
private Map<Item, Integer> components;
private Interactable requires;
private CraftingTable table;
private Integer requiresLvlAlchemy;
private Integer requiresLvlEngineering;
private Integer requiresLvlSmithery;
private Integer requiresLvlEnchanting;
}

View File

@@ -0,0 +1,152 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
/**
* Lädt und speichert {@link Recipe}-Instanzen als JSON.
* Dateiformat: {@code <createsItemId>.recipe} im recipes/-Verzeichnis.
*
* {@code Map<Item,Integer>} wird als Array von {@code {itemId, count}}-Einträgen
* gespeichert, da Gson keine komplexen Map-Keys unterstützt.
*/
public final class RecipeIO {
private static final Logger log = LoggerFactory.getLogger(RecipeIO.class);
private static final String EXTENSION = ".recipe";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
public static final Comparator<Recipe> SORT_ORDER = Comparator
.comparing((Recipe r) -> {
if (r.getTable() == null) return "";
CraftingTable.CraftingTableType t = r.getTable().getType();
return t != null ? t.name() : "";
})
.thenComparing(r -> r.getCreates() != null ? safe(r.getCreates().getItemId()) : "");
private RecipeIO() {}
// ── Public API ────────────────────────────────────────────────────────────
public static void save(Recipe recipe, Path recipeDir) throws IOException {
String fileId = fileId(recipe);
Files.createDirectories(recipeDir);
Files.writeString(recipeDir.resolve(fileId + EXTENSION),
GSON.toJson(toDto(recipe)), StandardCharsets.UTF_8);
log.debug("[RecipeIO] Gespeichert: {}", fileId);
}
public static Recipe load(Path file) throws IOException {
RecipeDto dto = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), RecipeDto.class);
return fromDto(dto);
}
public static List<Recipe> loadAll(Path recipeDir) {
List<Recipe> result = new ArrayList<>();
if (!Files.isDirectory(recipeDir)) return result;
try (Stream<Path> walk = Files.list(recipeDir)) {
walk.filter(p -> p.toString().endsWith(EXTENSION))
.sorted()
.forEach(p -> {
try { result.add(load(p)); }
catch (IOException e) { log.warn("[RecipeIO] Fehler: {}", e.getMessage()); }
});
} catch (IOException e) {
log.warn("[RecipeIO] Scan-Fehler: {}", e.getMessage());
}
result.sort(SORT_ORDER);
return result;
}
/** Löscht die Datei des alten fileId (vor einer Umbenennung). */
public static void delete(String oldCreatesItemId, Path recipeDir) throws IOException {
if (oldCreatesItemId != null && !oldCreatesItemId.isBlank())
Files.deleteIfExists(recipeDir.resolve(oldCreatesItemId + EXTENSION));
}
public static String fileId(Recipe r) {
if (r.getCreates() != null && r.getCreates().getItemId() != null
&& !r.getCreates().getItemId().isBlank())
return r.getCreates().getItemId();
return "unbenanntes_rezept";
}
// ── DTO conversion ────────────────────────────────────────────────────────
private static RecipeDto toDto(Recipe r) {
RecipeDto dto = new RecipeDto();
dto.createsItemId = r.getCreates() != null ? r.getCreates().getItemId() : null;
if (r.getComponents() != null) {
dto.components = new ArrayList<>();
r.getComponents().forEach((item, count) ->
dto.components.add(new ComponentDto(item.getItemId(), count)));
dto.components.sort(Comparator.comparing(c -> safe(c.itemId)));
}
if (r.getTable() != null && r.getTable().getType() != null)
dto.tableType = r.getTable().getType().name();
dto.requiresLvlAlchemy = r.getRequiresLvlAlchemy();
dto.requiresLvlEngineering = r.getRequiresLvlEngineering();
dto.requiresLvlSmithery = r.getRequiresLvlSmithery();
dto.requiresLvlEnchanting = r.getRequiresLvlEnchanting();
return dto;
}
static Recipe fromDto(RecipeDto dto) {
Recipe r = new Recipe();
if (dto.createsItemId != null) {
Item creates = new Item();
creates.setItemId(dto.createsItemId);
r.setCreates(creates);
}
if (dto.components != null) {
Map<Item, Integer> map = new LinkedHashMap<>();
for (ComponentDto c : dto.components) {
Item item = new Item();
item.setItemId(c.itemId);
map.put(item, c.count);
}
r.setComponents(map);
}
if (dto.tableType != null) {
try {
CraftingTable table = new CraftingTable();
table.setType(CraftingTable.CraftingTableType.valueOf(dto.tableType));
r.setTable(table);
} catch (IllegalArgumentException ignored) {}
}
r.setRequiresLvlAlchemy(dto.requiresLvlAlchemy);
r.setRequiresLvlEngineering(dto.requiresLvlEngineering);
r.setRequiresLvlSmithery(dto.requiresLvlSmithery);
r.setRequiresLvlEnchanting(dto.requiresLvlEnchanting);
return r;
}
// ── DTO classes ───────────────────────────────────────────────────────────
static class RecipeDto {
String createsItemId;
List<ComponentDto> components;
String tableType;
Integer requiresLvlAlchemy;
Integer requiresLvlEngineering;
Integer requiresLvlSmithery;
Integer requiresLvlEnchanting;
}
static class ComponentDto {
String itemId;
int count;
ComponentDto(String itemId, int count) { this.itemId = itemId; this.count = count; }
}
private static String safe(String s) { return s != null ? s : ""; }
}

View File

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

View File

@@ -0,0 +1,77 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
/**
* Lädt und speichert {@link TextBundle}-Instanzen als JSON.
* Dateiformat: {@code <lang>.json} im localization/-Verzeichnis.
*/
public final class TextBundleIO {
private static final Logger log = LoggerFactory.getLogger(TextBundleIO.class);
private static final String EXTENSION = ".json";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Type MAP_TYPE = new TypeToken<LinkedHashMap<String, String>>(){}.getType();
private TextBundleIO() {}
public static void save(TextBundle bundle, Path dir) throws IOException {
Files.createDirectories(dir);
Files.writeString(dir.resolve(bundle.getLanguage() + EXTENSION),
GSON.toJson(bundle.getEntries()), StandardCharsets.UTF_8);
log.debug("[TextBundleIO] Gespeichert: {}", bundle.getLanguage());
}
public static TextBundle load(Path file) throws IOException {
String lang = file.getFileName().toString().replace(EXTENSION, "");
Map<String, String> map = GSON.fromJson(
Files.readString(file, StandardCharsets.UTF_8), MAP_TYPE);
TextBundle bundle = new TextBundle(lang);
if (map != null) bundle.setEntries(new LinkedHashMap<>(map));
return bundle;
}
public static List<TextBundle> loadAll(Path dir) {
List<TextBundle> result = new ArrayList<>();
if (!Files.isDirectory(dir)) return result;
try (Stream<Path> walk = Files.list(dir)) {
walk.filter(p -> p.toString().endsWith(EXTENSION))
.sorted()
.forEach(p -> {
try { result.add(load(p)); }
catch (IOException e) { log.warn("[TextBundleIO] Fehler: {}", e.getMessage()); }
});
} catch (IOException e) {
log.warn("[TextBundleIO] Scan-Fehler: {}", e.getMessage());
}
return result;
}
public static List<String> availableLanguages(Path dir) {
List<String> langs = new ArrayList<>();
if (!Files.isDirectory(dir)) return langs;
try (Stream<Path> walk = Files.list(dir)) {
walk.filter(p -> p.toString().endsWith(EXTENSION))
.map(p -> p.getFileName().toString().replace(EXTENSION, ""))
.sorted()
.forEach(langs::add);
} catch (IOException ignored) {}
return langs;
}
public static void delete(String language, Path dir) throws IOException {
Files.deleteIfExists(dir.resolve(language + EXTENSION));
}
}

View File

@@ -0,0 +1,39 @@
package de.blight.common.model;
import java.util.HashMap;
import java.util.Map;
/**
* Statische Auflösungstabelle für {@link TextReference}-Schlüssel zur Laufzeit und im Editor.
* Wird durch {@code TextBundleIO} mit der aktiven Sprachversion befüllt.
*/
public final class TextRegistry {
private static final Map<String, String> entries = new HashMap<>();
private TextRegistry() {}
public static void registerAll(Map<String, String> map) {
entries.putAll(map);
}
public static void clear() {
entries.clear();
}
/** Löst eine TextReference auf. Gibt den Schlüssel zurück wenn kein Eintrag vorhanden. */
public static String resolve(TextReference ref) {
if (ref == null || ref.id() == null) return "";
return entries.getOrDefault(ref.id(), ref.id());
}
public static String resolve(TextReference ref, String fallback) {
if (ref == null) return fallback;
return entries.getOrDefault(ref.id(), fallback);
}
/** Direkter Zugriff für den Editor (alle Einträge). */
public static Map<String, String> getAll() {
return new HashMap<>(entries);
}
}

View File

@@ -37,6 +37,16 @@ public class Abilities {
*/
private int lvlEngineering; // 1-3
/**
* Levels ermöglichen das Schmieden von immer besseren Waffen und Ausrüstungen
*/
private int lvlSmithery; // 1-3
/**
* Levels ermöglichen das HErstellen von mächtigeren Gegenständen
*/
private int lvlEnchanting; // 1-3
public enum StaffAbilities {
BASE_ATTACK(1), // Eine Basisattacke mit einem Stab

View File

@@ -7,7 +7,7 @@ import lombok.Setter;
@Getter
@Setter
public class BringQuest {
public class BringQuest extends Quest {
private NPC bring;
private Location bringTo;

View File

@@ -7,7 +7,7 @@ import lombok.Setter;
@Setter
@Getter
public class FollowQuest implements QuestType {
public class FollowQuest extends Quest {
private NPC follow;
private Location followTo;

View File

@@ -6,7 +6,7 @@ import lombok.Setter;
@Getter
@Setter
public class InteractQuest implements QuestType {
public class InteractQuest extends Quest {
private Interactable interactWith;
}

View File

@@ -6,7 +6,7 @@ import lombok.Setter;
@Getter
@Setter
public class ItemQuest implements QuestType {
public class ItemQuest extends Quest {
private Item item;
private int count;

View File

@@ -6,13 +6,11 @@ import lombok.Setter;
@Getter
@Setter
public class Quest {
public abstract class Quest {
private int xp;
private String questId;
private TextReference text;
private TextReference description;
private TextReference successText;
private QuestType questType;
}

View File

@@ -0,0 +1,138 @@
package de.blight.common.model.quests;
import com.google.gson.*;
import de.blight.common.model.Interactable;
import de.blight.common.model.InteractableRef;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
/**
* Lädt und speichert {@link Quest}-Instanzen als JSON.
* Dateiformat: {@code <questId>.quest} im quests/-Verzeichnis.
*
* Typ-Diskriminator im JSON: {@code "type": "BRING" | "FOLLOW" | "INTERACT" | "ITEM" | "TALK"}
*/
public final class QuestIO {
private static final Logger log = LoggerFactory.getLogger(QuestIO.class);
private static final String EXTENSION = ".quest";
private static final Gson GSON = new GsonBuilder()
.setPrettyPrinting()
.registerTypeAdapter(Quest.class, new QuestAdapter())
.registerTypeAdapter(Interactable.class, new InteractableAdapter())
.create();
private QuestIO() {}
// ── Public API ────────────────────────────────────────────────────────────
public static void save(Quest quest, Path questDir) throws IOException {
if (quest.getQuestId() == null || quest.getQuestId().isBlank())
throw new IllegalArgumentException("questId darf nicht leer sein");
Files.createDirectories(questDir);
JsonObject obj = serializeWithType(quest);
Files.writeString(questDir.resolve(quest.getQuestId() + EXTENSION),
GSON.toJson(obj), StandardCharsets.UTF_8);
log.debug("[QuestIO] Gespeichert: {}", quest.getQuestId());
}
public static Quest load(Path file) throws IOException {
String json = Files.readString(file, StandardCharsets.UTF_8);
return GSON.fromJson(json, Quest.class);
}
public static List<Quest> loadAll(Path questDir) {
List<Quest> result = new ArrayList<>();
if (!Files.isDirectory(questDir)) return result;
try (Stream<Path> walk = Files.list(questDir)) {
walk.filter(p -> p.toString().endsWith(EXTENSION))
.sorted()
.forEach(p -> {
try { result.add(load(p)); }
catch (IOException e) { log.warn("[QuestIO] Fehler beim Laden: {}", e.getMessage()); }
});
} catch (IOException e) {
log.warn("[QuestIO] Verzeichnis-Scan fehlgeschlagen: {}", e.getMessage());
}
return result;
}
public static void delete(String questId, Path questDir) throws IOException {
Files.deleteIfExists(questDir.resolve(questId + EXTENSION));
}
// ── Serialisation helper ──────────────────────────────────────────────────
public static String typeOf(Quest q) {
if (q instanceof BringQuest) return "BRING";
if (q instanceof FollowQuest) return "FOLLOW";
if (q instanceof InteractQuest) return "INTERACT";
if (q instanceof ItemQuest) return "ITEM";
if (q instanceof TalkQuest) return "TALK";
return "UNKNOWN";
}
private static JsonObject serializeWithType(Quest quest) {
// Serialize using the concrete type to capture all fields
Gson plain = new GsonBuilder()
.setPrettyPrinting()
.registerTypeAdapter(Interactable.class, new InteractableAdapter())
.create();
JsonObject obj = plain.toJsonTree(quest, quest.getClass()).getAsJsonObject();
obj.addProperty("type", typeOf(quest));
return obj;
}
// ── Type adapters ─────────────────────────────────────────────────────────
static class QuestAdapter implements JsonDeserializer<Quest>, JsonSerializer<Quest> {
@Override
public Quest deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext ctx)
throws JsonParseException {
JsonObject obj = json.getAsJsonObject();
String type = obj.has("type") ? obj.get("type").getAsString() : "UNKNOWN";
// Deserialize as the concrete type (no type adapter registered for subtypes → no recursion)
Gson plain = new GsonBuilder()
.registerTypeAdapter(Interactable.class, new InteractableAdapter())
.create();
return switch (type) {
case "BRING" -> plain.fromJson(json, BringQuest.class);
case "FOLLOW" -> plain.fromJson(json, FollowQuest.class);
case "INTERACT" -> plain.fromJson(json, InteractQuest.class);
case "ITEM" -> plain.fromJson(json, ItemQuest.class);
case "TALK" -> plain.fromJson(json, TalkQuest.class);
default -> plain.fromJson(json, TalkQuest.class);
};
}
@Override
public JsonElement serialize(Quest src, Type typeOfSrc, JsonSerializationContext ctx) {
return serializeWithType(src);
}
}
static class InteractableAdapter implements JsonDeserializer<Interactable>, JsonSerializer<Interactable> {
@Override
public Interactable deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext ctx)
throws JsonParseException {
return new Gson().fromJson(json, InteractableRef.class);
}
@Override
public JsonElement serialize(Interactable src, Type typeOfSrc, JsonSerializationContext ctx) {
return new Gson().toJsonTree(src, InteractableRef.class);
}
}
}

View File

@@ -1,5 +0,0 @@
package de.blight.common.model.quests;
public interface QuestType {
}

View File

@@ -6,7 +6,7 @@ import lombok.Setter;
@Getter
@Setter
public class TalkQuest implements QuestType {
public class TalkQuest extends Quest {
private NPC talkTo;
}

View File

@@ -0,0 +1,29 @@
package de.blight.common.model.trigger;
import de.blight.common.model.MainCharacter;
import de.blight.common.model.Status;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
/**
* Ändert den Status aller NPCs einer Fraktion (per Fraktions-UUID) wenn betreten.
*/
@Getter
@Setter
public class FractionStatusTrigger extends Trigger {
private UUID fractionId;
private Status targetStatus;
@Override
public boolean isTriggarableDelegate(MainCharacter character) {
return fractionId != null && targetStatus != null;
}
@Override
public void trigger(MainCharacter character) {
// Laufzeit: alle NPCs der Fraktion suchen und Status setzen
}
}

View File

@@ -0,0 +1,28 @@
package de.blight.common.model.trigger;
import de.blight.common.model.MainCharacter;
import de.blight.common.model.Status;
import lombok.Getter;
import lombok.Setter;
/**
* Ändert den Status eines bestimmten NPCs (per Character-ID) wenn betreten.
* Die eigentliche NPC-Suche zur Laufzeit obliegt der Game-Registry.
*/
@Getter
@Setter
public class NpcStatusTrigger extends Trigger {
private String npcId;
private Status targetStatus;
@Override
public boolean isTriggarableDelegate(MainCharacter character) {
return npcId != null && !npcId.isBlank() && targetStatus != null;
}
@Override
public void trigger(MainCharacter character) {
// Laufzeit: NPC per ID suchen und Status setzen
}
}

View File

@@ -0,0 +1,23 @@
package de.blight.common.model.trigger;
import de.blight.common.model.MainCharacter;
import de.blight.common.model.quests.Quest;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class QuestStartTrigger extends Trigger {
private Quest quest;
@Override
public boolean isTriggarableDelegate(MainCharacter character) {
return quest != null && character.isQuestNew(quest);
}
@Override
public void trigger(MainCharacter character) {
if (quest != null) character.startQuest(quest);
}
}

View File

@@ -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 abstract class Trigger {
private int requiresChapter;
public boolean isTriggarable(MainCharacter character) {
return character.getChapter() >= requiresChapter && isTriggarableDelegate(character);
}
public abstract boolean isTriggarableDelegate(MainCharacter character);
public abstract void trigger(MainCharacter character);
}

View File

@@ -0,0 +1,136 @@
package de.blight.common.model.trigger;
import com.google.gson.*;
import de.blight.common.model.QuestRef;
import de.blight.common.model.Status;
import java.lang.reflect.Type;
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:
* <ul>
* <li>{@code QUEST_START} → {@link QuestStartTrigger}</li>
* <li>{@code NPC_STATUS} → {@link NpcStatusTrigger}</li>
* <li>{@code FRACTION_STATUS} → {@link FractionStatusTrigger}</li>
* </ul>
*/
public final class TriggerIO {
public static final String TYPE_QUEST_START = "QUEST_START";
public static final String TYPE_NPC_STATUS = "NPC_STATUS";
public static final String TYPE_FRACTION_STATUS = "FRACTION_STATUS";
private static final Gson GSON = new GsonBuilder()
.registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter())
.create();
private TriggerIO() {}
/** Serialisiert eine Trigger-Liste als kompaktes JSON (kein Zeilenumbruch). */
public static String serializeList(List<Trigger> 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<Trigger> deserializeList(String json) {
if (json == null || json.isBlank() || "[]".equals(json.strip())) return new ArrayList<>();
try {
JsonArray arr = JsonParser.parseString(json).getAsJsonArray();
List<Trigger> result = new ArrayList<>();
for (JsonElement el : arr) {
Trigger t = GSON.fromJson(el, Trigger.class);
if (t != null) result.add(t);
}
return result;
} catch (Exception e) {
return new ArrayList<>();
}
}
// ── Adapter ───────────────────────────────────────────────────────────────
static class TriggerAdapter implements JsonSerializer<Trigger>, JsonDeserializer<Trigger> {
@Override
public JsonElement serialize(Trigger src, Type typeOfSrc, JsonSerializationContext ctx) {
JsonObject obj = new JsonObject();
obj.addProperty("type", typeOf(src));
obj.addProperty("requiresChapter", src.getRequiresChapter());
if (src instanceof QuestStartTrigger q) {
if (q.getQuest() != null && q.getQuest().getQuestId() != null)
obj.addProperty("questId", q.getQuest().getQuestId());
} else if (src instanceof NpcStatusTrigger n) {
if (n.getNpcId() != null) obj.addProperty("npcId", n.getNpcId());
if (n.getTargetStatus() != null)
obj.addProperty("targetStatus", n.getTargetStatus().name());
} else if (src instanceof FractionStatusTrigger f) {
if (f.getFractionId() != null)
obj.addProperty("fractionId", f.getFractionId().toString());
if (f.getTargetStatus() != null)
obj.addProperty("targetStatus", f.getTargetStatus().name());
}
return obj;
}
@Override
public Trigger deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext ctx)
throws JsonParseException {
JsonObject obj = json.getAsJsonObject();
String type = obj.has("type") ? obj.get("type").getAsString() : "";
Trigger t = switch (type) {
case TYPE_QUEST_START -> {
QuestStartTrigger q = new QuestStartTrigger();
if (obj.has("questId")) {
QuestRef ref = new QuestRef();
ref.setQuestId(obj.get("questId").getAsString());
q.setQuest(ref);
}
yield q;
}
case TYPE_NPC_STATUS -> {
NpcStatusTrigger n = new NpcStatusTrigger();
if (obj.has("npcId")) n.setNpcId(obj.get("npcId").getAsString());
if (obj.has("targetStatus")) n.setTargetStatus(parseStatus(obj.get("targetStatus")));
yield n;
}
case TYPE_FRACTION_STATUS -> {
FractionStatusTrigger f = new FractionStatusTrigger();
if (obj.has("fractionId")) {
try { f.setFractionId(UUID.fromString(obj.get("fractionId").getAsString())); }
catch (IllegalArgumentException ignored) {}
}
if (obj.has("targetStatus")) f.setTargetStatus(parseStatus(obj.get("targetStatus")));
yield f;
}
default -> null;
};
if (t != null && obj.has("requiresChapter"))
t.setRequiresChapter(obj.get("requiresChapter").getAsInt());
return t;
}
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;
return "UNKNOWN";
}
private static Status parseStatus(JsonElement el) {
try { return Status.valueOf(el.getAsString()); }
catch (IllegalArgumentException ignored) { return null; }
}
}
}