Animations-Import, Massenimport-Queue, Asset-Archivierung, Voxel-Refactor

- Animations-Import: GLB wird direkt vom Ursprungspfad geladen (kein Zwischenkopieren), J3O in clips/ gespeichert
- RetargetingSystem: Translations-Tracks im Full-Retarget-Pfad erhalten (Hips-Y für sit_down)
- AnimationLibrary: lädt nur J3O, Clip-Name wird bei applyTo() auf Library-Key umbenannt
- SharedInput: animPreviewAddAnimPath → ConcurrentLinkedQueue animImportQueue (Massenimport-Fix)
- EditorApp: archiveOriginal() archiviert Originaldateien nach assets/imported/<assettyp>/
- EditorApp: Animations-Unterknoten im Asset-Baum zeigen enthaltene Clip-Namen
- Neue Animations-Clips: sit_down, get_up_sitting, sitting, pickup, sprinting u.a.
- Voxel: VoxelChunkState entfernt, VoxelChunkNode/MarchingCubes überarbeitet
- Map: Voxel-Chunks bereinigt, Terrain-Chunks aktualisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:52:04 +02:00
parent a369647e9c
commit c8f1dd9432
239 changed files with 8234 additions and 658 deletions

View File

@@ -27,7 +27,12 @@ public record ModelMeta(
float lod2Distance,
float cullDistance,
List<AttachedLight> attachedLights,
List<AttachedEmitter> attachedEmitters
List<AttachedEmitter> attachedEmitters,
de.blight.common.model.InteractableType interactableType,
float interactableOffsetX,
float interactableOffsetY,
float interactableOffsetZ,
float interactableRotY
) {
/** Lichtquelle relativ zum Modell-Ursprung. */
public record AttachedLight(
@@ -45,6 +50,8 @@ public record ModelMeta(
return new ModelMeta(name, "", "", 1f, 1f, 1f, true, 0f, 0f,
false, true, true, 1f, 1f,
"", "", 30f, 80f, 120f,
List.of(), List.of());
List.of(), List.of(),
de.blight.common.model.InteractableType.NONE,
0f, 0.5f, 0f, 0f);
}
}

View File

@@ -34,6 +34,11 @@ public final class ModelMetaIO {
p.setProperty("lod1Distance", String.valueOf(m.lod1Distance()));
p.setProperty("lod2Distance", String.valueOf(m.lod2Distance()));
p.setProperty("cullDistance", String.valueOf(m.cullDistance()));
p.setProperty("interactableType", m.interactableType().name());
p.setProperty("interactableOffsetX", String.valueOf(m.interactableOffsetX()));
p.setProperty("interactableOffsetY", String.valueOf(m.interactableOffsetY()));
p.setProperty("interactableOffsetZ", String.valueOf(m.interactableOffsetZ()));
p.setProperty("interactableRotY", String.valueOf(m.interactableRotY()));
// Anhänge: Lichter
List<ModelMeta.AttachedLight> lights = m.attachedLights();
@@ -127,7 +132,13 @@ public final class ModelMetaIO {
parseFloat(p, "lod2Distance", 80f),
parseFloat(p, "cullDistance", 120f),
Collections.unmodifiableList(lights),
Collections.unmodifiableList(emitters)
Collections.unmodifiableList(emitters),
de.blight.common.model.InteractableType.fromString(
p.getProperty("interactableType", "NONE")),
parseFloat(p, "interactableOffsetX", 0f),
parseFloat(p, "interactableOffsetY", 0.5f),
parseFloat(p, "interactableOffsetZ", 0f),
parseFloat(p, "interactableRotY", 0f)
);
}

View File

@@ -18,5 +18,9 @@ public record PlacedModel(
String lod2Path,
float lod1Distance, // ab dieser Distanz LOD1 anzeigen
float lod2Distance, // ab dieser Distanz LOD2 anzeigen
float cullDistance // ab dieser Distanz ausblenden
float cullDistance, // ab dieser Distanz ausblenden
/** "CRAFTING_TABLE" / "BED" / "" für kein Interactable. */
String interactableType,
/** ID des verknüpften Interactables (CraftingTableType-Name oder Bett-UUID); "" wenn nicht gesetzt. */
String interactableId
) {}

View File

@@ -8,11 +8,11 @@ import java.util.*;
* Liest und schreibt platzierte Modelle als tab-separierte Textdatei
* ({@code blight_objects.blo}) neben der Kartendatei.
*
* Spalten (seit v4):
* Spalten (seit v5):
* modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow
* lod1Path lod2Path lod1Distance lod2Distance cullDistance
* lod1Path lod2Path lod1Distance lod2Distance cullDistance interactableType interactableId
*
* Alte Dateien mit 6 Spalten (v1/v2/v3) werden gelesen; fehlende Felder erhalten Standardwerte.
* Alte Dateien mit 6 Spalten (v1v4) werden gelesen; fehlende Felder erhalten Standardwerte.
*/
public final class PlacedModelIO {
@@ -26,11 +26,11 @@ public final class PlacedModelIO {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow\tlod1Path\tlod2Path\tlod1Distance\tlod2Distance\tcullDistance");
w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow\tlod1Path\tlod2Path\tlod1Distance\tlod2Distance\tcullDistance\tinteractableType\tinteractableId");
w.newLine();
for (PlacedModel m : models) {
w.write(String.format(Locale.ROOT,
"%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s\t%b\t%b\t%s\t%s\t%.5f\t%.5f\t%.5f%n",
"%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s\t%b\t%b\t%s\t%s\t%.5f\t%.5f\t%.5f\t%s\t%s%n",
m.modelPath(),
m.x(), m.y(), m.z(),
m.rotY(), m.scale(),
@@ -40,7 +40,8 @@ public final class PlacedModelIO {
nvl(m.meshFile()), nvl(m.animClip()),
m.castShadow(), m.receiveShadow(),
nvl(m.lod1Path()), nvl(m.lod2Path()),
m.lod1Distance(), m.lod2Distance(), m.cullDistance()));
m.lod1Distance(), m.lod2Distance(), m.cullDistance(),
nvl(m.interactableType()), nvl(m.interactableId())));
}
}
}
@@ -75,12 +76,15 @@ public final class PlacedModelIO {
String lod2Path = f.length > 17 ? f[17] : "";
float lod1Distance = f.length > 18 ? parseFloat(f[18], 30f) : 30f;
float lod2Distance = f.length > 19 ? parseFloat(f[19], 80f) : 80f;
float cullDistance = f.length > 20 ? parseFloat(f[20], 120f) : 120f;
float cullDistance = f.length > 20 ? parseFloat(f[20], 120f) : 120f;
String interactableType = f.length > 21 ? f[21] : "";
String interactableId = f.length > 22 ? f[22] : "";
list.add(new PlacedModel(modelPath, x, y, z,
rotY, rotX, rotZ, scale, solid,
texPath, nmPath, matPath, meshFile, animClip,
castShadow, receiveShadow,
lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance));
lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance,
interactableType, interactableId));
} catch (NumberFormatException ignored) {}
}
return list;

View File

@@ -0,0 +1,18 @@
package de.blight.common;
/**
* Prozedural generierter Stein.
* Y-Position wird zur Laufzeit aus dem Terrain berechnet.
*
* sinkFraction: Anteil des Durchmessers, der unter der Terrainoberfläche liegt (0.20.5).
* noiseSeed: Seed für die deterministisch reproduzierbare Verformung.
*/
public record PlacedStone(
float x,
float z,
float radius,
float rotY,
int textureSlot,
float sinkFraction,
int noiseSeed
) {}

View File

@@ -0,0 +1,92 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
/**
* Liest und schreibt platzierte Steine als komprimierte Binärdatei
* ({@code blight_stones.bls}) neben der Kartendatei.
*
* Format v1:
* int MAGIC 0x53544E53 ("STNS")
* int VERSION 1
* int SLOT_COUNT 3
* 3× UTF Texturpfad pro Slot ("" = kein)
* int stoneCount
* N× float x, float z, float radius, float rotY,
* byte textureSlot, float sinkFraction, int noiseSeed
*/
public final class PlacedStoneIO {
private static final int MAGIC = 0x53544E53;
private static final int VERSION = 1;
public static final int SLOT_COUNT = 3;
private PlacedStoneIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_stones.bls");
}
public record StoneData(String[] slotPaths, List<PlacedStone> stones) {}
public static void save(StoneData data) 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);
String[] paths = data.slotPaths() != null ? data.slotPaths() : new String[0];
out.writeInt(SLOT_COUNT);
for (int i = 0; i < SLOT_COUNT; i++) {
String s = (i < paths.length && paths[i] != null) ? paths[i] : "";
out.writeUTF(s);
}
List<PlacedStone> stones = data.stones() != null ? data.stones() : List.of();
out.writeInt(stones.size());
for (PlacedStone s : stones) {
out.writeFloat(s.x());
out.writeFloat(s.z());
out.writeFloat(s.radius());
out.writeFloat(s.rotY());
out.writeByte(s.textureSlot());
out.writeFloat(s.sinkFraction());
out.writeInt(s.noiseSeed());
}
}
}
public static StoneData load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return null;
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new GZIPInputStream(Files.newInputStream(p))))) {
if (in.readInt() != MAGIC) throw new IOException("Kein PlacedStone-Magic");
int ver = in.readInt();
if (ver > VERSION) throw new IOException("Unbekannte Stone-Version: " + ver);
int slotCount = in.readInt();
String[] paths = new String[SLOT_COUNT];
Arrays.fill(paths, "");
for (int i = 0; i < slotCount; i++) {
String s = in.readUTF();
if (i < SLOT_COUNT) paths[i] = s;
}
int count = in.readInt();
List<PlacedStone> stones = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
float x = in.readFloat();
float z = in.readFloat();
float r = in.readFloat();
float rotY = in.readFloat();
int slot = in.readUnsignedByte();
float sink = in.readFloat();
int seed = in.readInt();
stones.add(new PlacedStone(x, z, r, rotY, slot, sink, seed));
}
return new StoneData(paths, stones);
}
}
}

View File

@@ -0,0 +1,20 @@
package de.blight.common;
/**
* Gespeicherte Vertex-Positionen eines gebackenen Voxel-Chunks nach dem Sculpten.
* Vertex-Anzahl und -Reihenfolge entsprechen dem LOD0-Bake
* ({@code voxel_CX_CY_CZ_baked_lod0.j3o}).
*/
public class SculptedMesh {
public final int cx, cy, cz;
/** Vertex-Positionen im lokalen Chunk-Raum (xyz je Vertex). */
public final float[] positions;
public SculptedMesh(int cx, int cy, int cz, float[] positions) {
this.cx = cx;
this.cy = cy;
this.cz = cz;
this.positions = positions;
}
}

View File

@@ -0,0 +1,100 @@
package de.blight.common;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
/**
* Lesen und Schreiben von {@link SculptedMesh}-Daten als {@code .blsm}-Dateien.
*
* Format: MAGIC (4), VERSION (4), cx (4), cy (4), cz (4), vertexCount (4),
* positions[vertexCount*3] (float32 LE)
*/
public final class SculptedMeshIO {
private static final int MAGIC = 0x424C534D; // 'BLSM'
private static final int VERSION = 1;
private SculptedMeshIO() {}
public static Path getPath(int cx, int cy, int cz) {
String cyStr = cy < 0 ? "m" + (-cy) : String.valueOf(cy);
return ChunkTerrainIO.chunksDir()
.resolve(String.format("sculpt_%02d_%s_%02d.blsm", cx, cyStr, cz));
}
public static boolean exists(int cx, int cy, int cz) {
return Files.exists(getPath(cx, cy, cz));
}
public static void save(SculptedMesh mesh) throws IOException {
int vcount = mesh.positions.length / 3;
ByteBuffer buf = ByteBuffer.allocate(6 * 4 + vcount * 3 * 4)
.order(ByteOrder.LITTLE_ENDIAN);
buf.putInt(MAGIC);
buf.putInt(VERSION);
buf.putInt(mesh.cx);
buf.putInt(mesh.cy);
buf.putInt(mesh.cz);
buf.putInt(vcount);
for (float f : mesh.positions) buf.putFloat(f);
Path p = getPath(mesh.cx, mesh.cy, mesh.cz);
Path tmp = p.resolveSibling(p.getFileName() + ".tmp");
Files.createDirectories(p.getParent());
Files.write(tmp, buf.array());
try {
Files.move(tmp, p, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, p, StandardCopyOption.REPLACE_EXISTING);
}
}
public static SculptedMesh load(int cx, int cy, int cz) throws IOException {
byte[] data = Files.readAllBytes(getPath(cx, cy, cz));
ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
if (buf.getInt() != MAGIC) throw new IOException("Ungültiger MAGIC");
if (buf.getInt() != VERSION) throw new IOException("Ungültige VERSION");
int lcx = buf.getInt();
int lcy = buf.getInt();
int lcz = buf.getInt();
int vcount = buf.getInt();
float[] positions = new float[vcount * 3];
for (int i = 0; i < positions.length; i++) positions[i] = buf.getFloat();
return new SculptedMesh(lcx, lcy, lcz, positions);
}
public static void delete(int cx, int cy, int cz) throws IOException {
Files.deleteIfExists(getPath(cx, cy, cz));
}
/**
* Gibt alle Chunks zurück, für die ein gebackenes LOD0-Mesh ({@code voxel_*_baked_lod0.j3o})
* existiert. Jeder Eintrag ist ein int[]{cx, cy, cz}.
*/
public static List<int[]> findAllBakedChunks() {
List<int[]> result = new ArrayList<>();
Path dir = ChunkTerrainIO.chunksDir();
if (!Files.isDirectory(dir)) return result;
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, "voxel_*_baked_lod0.j3o")) {
for (Path p : ds) {
String name = p.getFileName().toString()
.replace("voxel_", "").replace("_baked_lod0.j3o", "");
String[] parts = name.split("_");
if (parts.length != 3) continue;
try {
int cx = Integer.parseInt(parts[0]);
int cy = parts[1].startsWith("m")
? -Integer.parseInt(parts[1].substring(1))
: Integer.parseInt(parts[1]);
int cz = Integer.parseInt(parts[2]);
result.add(new int[]{cx, cy, cz});
} catch (NumberFormatException ignored) {}
}
} catch (IOException ignored) {}
return result;
}
}

View File

@@ -67,6 +67,26 @@ public final class VoxelChunk {
public boolean isEmpty() { return density == null; }
public void clear() { density = null; material = null; dirty = true; }
/**
* Gibt die Y-Ausdehnung (in Voxel) der soliden Voxel zurück.
* 0 = keine soliden Voxel; 1 = alle soliden Voxel auf einer Y-Ebene (flache Schicht).
* Chunks mit Span < 2 erzeugen nur eine flache Mesh-Fläche und sollen nicht gerendert werden.
*/
public int solidYSpan() {
if (density == null) return 0;
int minY = SIZE, maxY = -1;
for (int i = 0; i < density.length; i++) {
if (density[i] > 0) {
int y = i / (SIZE * SIZE);
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
return maxY < 0 ? 0 : maxY - minY + 1;
}
// ── Kugelförmiger Pinsel ──────────────────────────────────────────────────
/**
@@ -99,6 +119,92 @@ public final class VoxelChunk {
}
}
/**
* Verringert die Dichte aller soliden Voxel innerhalb des Radius graduell um {@code step}.
* Voxel mit Dichte <= 0 werden nicht berührt. Gedacht für das Tunnel-Werkzeug.
*/
public void reduceDensity(float localX, float localY, float localZ, float radius, int step) {
if (density == null) return;
int x0 = Math.max(0, (int)(localX - radius));
int x1 = Math.min(SIZE-1, (int)Math.ceil(localX + radius));
int y0 = Math.max(0, (int)(localY - radius));
int y1 = Math.min(SIZE-1, (int)Math.ceil(localY + radius));
int z0 = Math.max(0, (int)(localZ - radius));
int z1 = Math.min(SIZE-1, (int)Math.ceil(localZ + radius));
float r2 = radius * radius;
boolean changed = false;
for (int y = y0; y <= y1; y++) {
float dy = y - localY;
for (int z = z0; z <= z1; z++) {
float dz = z - localZ;
for (int x = x0; x <= x1; x++) {
float dx = x - localX;
if (dx*dx + dy*dy + dz*dz > r2) continue;
int i = idx(x, y, z);
int d = density[i];
if (d <= 0) continue;
density[i] = (byte) Math.max(Byte.MIN_VALUE, d - step);
changed = true;
}
}
}
if (changed) dirty = true;
}
/**
* Entfernt vollständig isolierte solide Voxel (alle 6 Flächennachbarn == Luft)
* in der gegebenen Region + 1 Voxel Rand. Randvoxel des Chunks (Index 0/SIZE-1)
* werden übersprungen um Cross-Chunk-Artefakte zu vermeiden.
*/
public void pruneIsolated(float localX, float localY, float localZ, float radius) {
if (density == null) return;
int x0 = Math.max(1, (int)(localX - radius) - 1);
int x1 = Math.min(SIZE-2, (int)Math.ceil(localX + radius) + 1);
int y0 = Math.max(1, (int)(localY - radius) - 1);
int y1 = Math.min(SIZE-2, (int)Math.ceil(localY + radius) + 1);
int z0 = Math.max(1, (int)(localZ - radius) - 1);
int z1 = Math.min(SIZE-2, (int)Math.ceil(localZ + radius) + 1);
boolean changed = false;
for (int y = y0; y <= y1; y++) {
for (int z = z0; z <= z1; z++) {
for (int x = x0; x <= x1; x++) {
if (density[idx(x, y, z)] <= 0) continue;
if (density[idx(x+1,y,z)] <= 0 && density[idx(x-1,y,z)] <= 0 &&
density[idx(x,y+1,z)] <= 0 && density[idx(x,y-1,z)] <= 0 &&
density[idx(x,y,z+1)] <= 0 && density[idx(x,y,z-1)] <= 0) {
density[idx(x, y, z)] = Byte.MIN_VALUE;
changed = true;
}
}
}
}
if (changed) dirty = true;
}
/** Gibt eine Kopie des Dichte-Arrays zurück, oder null wenn der Chunk leer ist. */
public byte[] getDensityCopy() {
return density != null ? density.clone() : null;
}
/** Setzt das Dichte-Array direkt (für Undo/Redo). */
public void setDensityArray(byte[] d) {
this.density = d;
dirty = true;
}
/**
* Füllt eine dünne horizontale Platte (ly0..ly1) als Solid (127), alles andere Luft.
* Setzt dirty nicht.
*/
public void fillThinSlab(int ly0, int ly1) {
if (density == null) density = new byte[SIZE * SIZE * SIZE];
Arrays.fill(density, Byte.MIN_VALUE);
for (int y = ly0; y <= ly1; y++)
for (int z = 0; z < SIZE; z++)
for (int x = 0; x < SIZE; x++)
density[idx(x, y, z)] = (byte) 127;
}
// ── Serialisierung ────────────────────────────────────────────────────────
public byte[] serialize() throws IOException {

View File

@@ -25,6 +25,10 @@ public final class VoxelChunkIO {
return Files.exists(getPath(cx, cy, cz));
}
public static void delete(int cx, int cy, int cz) throws IOException {
Files.deleteIfExists(getPath(cx, cy, cz));
}
public static void save(VoxelChunk chunk) throws IOException {
Path p = getPath(chunk.cx, chunk.cy, chunk.cz);
Path tmp = p.resolveSibling(p.getFileName() + ".tmp");
@@ -54,6 +58,22 @@ public final class VoxelChunkIO {
return Files.exists(getBakedPath(cx, cy, cz, 0));
}
/**
* true wenn das gebackene LOD0-Mesh existiert UND nicht älter als die
* .blvc-Quelldatei ist. Verhindert, dass veraltete Bakes nach einer
* Editor-Bearbeitung weiter genutzt werden.
*/
public static boolean bakedIsFresh(int cx, int cy, int cz) {
try {
Path baked = getBakedPath(cx, cy, cz, 0);
if (!Files.exists(baked)) return false;
return Files.getLastModifiedTime(baked).compareTo(
Files.getLastModifiedTime(getPath(cx, cy, cz))) >= 0;
} catch (IOException e) {
return false;
}
}
/**
* Liest alle vorhandenen VoxelChunks aus dem Chunks-Verzeichnis.
* Gibt leere Liste zurück wenn kein Chunks-Verzeichnis existiert.

View File

@@ -0,0 +1,43 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
public class Bed implements Interactable {
private String id;
private TextReference name;
private BedType bedType = BedType.Single;
/** Liegeposition Mittelpunkt der 1,8m Fläche. */
private float liegeX = 0f;
private float liegeZ = 0f;
/** Terrain-Höhe am Mittelpunkt (wird beim Platzieren gesetzt). */
private float liegeY = 0f;
/** Rotation um die Y-Achse in Radiant; Pfeilspitze = Kopfende. */
private float liegeRotY = 0f;
/** Gibt an, ob eine Liegefläche bereits definiert wurde. */
private boolean liegeSet = false;
public enum BedType {
Single,
Double;
}
public Bed() {
this.id = UUID.randomUUID().toString();
}
public Bed(String id) {
this.id = id;
}
@Override
public String getDisplayText() {
return TextRegistry.resolve(name, id != null ? id : "Bett");
}
}

View File

@@ -0,0 +1,95 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import de.blight.common.MapIO;
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.Optional;
/**
* Speichert Bett-Daten als {@code beds/<uuid>.bed}-JSON neben der Kartendatei.
*/
public final class BedIO {
private static final Logger log = LoggerFactory.getLogger(BedIO.class);
private static final String EXTENSION = ".bed";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private BedIO() {}
public static Path getDir() {
return MapIO.getMapPath().resolveSibling("beds");
}
public static void save(Bed bed) throws IOException {
if (bed.getId() == null) throw new IOException("Bett ohne ID kann nicht gespeichert werden.");
Path dir = getDir();
Files.createDirectories(dir);
Files.writeString(dir.resolve(bed.getId() + EXTENSION),
GSON.toJson(toDto(bed)), StandardCharsets.UTF_8);
}
public static Optional<Bed> load(String id) {
Path file = getDir().resolve(id + 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, id));
} catch (IOException e) {
log.warn("[BedIO] Fehler beim Laden von {}: {}", id, e.getMessage());
return Optional.empty();
}
}
public static void delete(String id) throws IOException {
Files.deleteIfExists(getDir().resolve(id + EXTENSION));
}
// ── DTO ───────────────────────────────────────────────────────────────────
private static Dto toDto(Bed b) {
Dto dto = new Dto();
dto.id = b.getId();
dto.bedType = b.getBedType() != null ? b.getBedType().name() : null;
dto.nameId = b.getName() != null ? b.getName().id() : null;
dto.liegeX = b.getLiegeX();
dto.liegeY = b.getLiegeY();
dto.liegeZ = b.getLiegeZ();
dto.liegeRotY = b.getLiegeRotY();
dto.liegeSet = b.isLiegeSet();
return dto;
}
private static Bed fromDto(Dto dto, String fallbackId) {
Bed b = new Bed(dto.id != null ? dto.id : fallbackId);
if (dto.bedType != null) {
try { b.setBedType(Bed.BedType.valueOf(dto.bedType)); }
catch (IllegalArgumentException ignored) {}
}
if (dto.nameId != null && !dto.nameId.isBlank())
b.setName(new TextReference(dto.nameId));
b.setLiegeX(dto.liegeX);
b.setLiegeY(dto.liegeY);
b.setLiegeZ(dto.liegeZ);
b.setLiegeRotY(dto.liegeRotY);
b.setLiegeSet(dto.liegeSet);
return b;
}
private static class Dto {
String id;
String bedType;
String nameId;
float liegeX;
float liegeY;
float liegeZ;
float liegeRotY;
boolean liegeSet;
}
}

View File

@@ -0,0 +1,44 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
public class Bench implements Interactable {
private String id;
private TextReference name;
public enum BenchType {
Simple,
Long;
}
private BenchType benchType = BenchType.Simple;
/** Sitzposition Mittelpunkt der 0,5m Fläche. */
private float sitzX = 0f;
private float sitzZ = 0f;
/** Terrain-Höhe am Mittelpunkt. */
private float sitzY = 0f;
/** Rotation um die Y-Achse in Radiant; Pfeilspitze = Blickrichtung beim Sitzen. */
private float sitzRotY = 0f;
/** Gibt an, ob eine Sitzfläche bereits definiert wurde. */
private boolean sitzSet = false;
public Bench() {
this.id = UUID.randomUUID().toString();
}
public Bench(String id) {
this.id = id;
}
@Override
public String getDisplayText() {
return TextRegistry.resolve(name, id != null ? id : "Bank");
}
}

View File

@@ -0,0 +1,95 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import de.blight.common.MapIO;
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.Optional;
/**
* Speichert Bank-Daten als {@code benches/<uuid>.bench}-JSON neben der Kartendatei.
*/
public final class BenchIO {
private static final Logger log = LoggerFactory.getLogger(BenchIO.class);
private static final String EXTENSION = ".bench";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private BenchIO() {}
public static Path getDir() {
return MapIO.getMapPath().resolveSibling("benches");
}
public static void save(Bench bench) throws IOException {
if (bench.getId() == null) throw new IOException("Bank ohne ID kann nicht gespeichert werden.");
Path dir = getDir();
Files.createDirectories(dir);
Files.writeString(dir.resolve(bench.getId() + EXTENSION),
GSON.toJson(toDto(bench)), StandardCharsets.UTF_8);
}
public static Optional<Bench> load(String id) {
Path file = getDir().resolve(id + 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, id));
} catch (IOException e) {
log.warn("[BenchIO] Fehler beim Laden von {}: {}", id, e.getMessage());
return Optional.empty();
}
}
public static void delete(String id) throws IOException {
Files.deleteIfExists(getDir().resolve(id + EXTENSION));
}
// ── DTO ───────────────────────────────────────────────────────────────────
private static Dto toDto(Bench b) {
Dto dto = new Dto();
dto.id = b.getId();
dto.benchType = b.getBenchType() != null ? b.getBenchType().name() : null;
dto.nameId = b.getName() != null ? b.getName().id() : null;
dto.sitzX = b.getSitzX();
dto.sitzY = b.getSitzY();
dto.sitzZ = b.getSitzZ();
dto.sitzRotY = b.getSitzRotY();
dto.sitzSet = b.isSitzSet();
return dto;
}
private static Bench fromDto(Dto dto, String fallbackId) {
Bench b = new Bench(dto.id != null ? dto.id : fallbackId);
if (dto.benchType != null) {
try { b.setBenchType(Bench.BenchType.valueOf(dto.benchType)); }
catch (IllegalArgumentException ignored) {}
}
if (dto.nameId != null && !dto.nameId.isBlank())
b.setName(new TextReference(dto.nameId));
b.setSitzX(dto.sitzX);
b.setSitzY(dto.sitzY);
b.setSitzZ(dto.sitzZ);
b.setSitzRotY(dto.sitzRotY);
b.setSitzSet(dto.sitzSet);
return b;
}
private static class Dto {
String id;
String benchType;
String nameId;
float sitzX;
float sitzY;
float sitzZ;
float sitzRotY;
boolean sitzSet;
}
}

View File

@@ -12,18 +12,25 @@ import lombok.Setter;
*/
@Getter
@Setter
public class CraftingTable {
public class CraftingTable implements Interactable {
private TextReference name;
private ObjectReference object;
private CraftingTableType type;
@Override
public String getDisplayText() {
return TextRegistry.resolve(name, type != null ? type.name() : "?");
}
public enum CraftingTableType {
AlchemyTable,
EnchantmentTable,
Smithy,
Goldsmiths,
Workshop;
Workshop,
Fireplace,
Kitchen;
}
}

View File

@@ -0,0 +1,22 @@
package de.blight.common.model;
public enum InteractableType {
NONE("Keines"),
CRAFTING_TABLE("Handwerkstisch"),
BED("Bett"),
BENCH("Bank");
private final String label;
InteractableType(String label) {
this.label = label;
}
public String getLabel() { return label; }
public static InteractableType fromString(String s) {
if (s == null || s.isBlank()) return NONE;
try { return valueOf(s); }
catch (IllegalArgumentException e) { return NONE; }
}
}

View File

@@ -1,5 +1,6 @@
package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
@@ -17,51 +18,88 @@ public class NPC extends GameCharacter {
private Status status;
private boolean trader;
private Fraction fraction;
private List<Item> items;
private List<DialogOption> currentOptions;
/** Tagesabläufe dieses NPCs. Die erste Routine gilt standardmäßig als aktiv. */
private List<NpcRoutine> routines = new ArrayList<>();
/**
* Name der aktuell aktiven Routine. {@code null} oder kein Treffer
* → erste Routine in der Liste ist aktiv.
* Wird zur Laufzeit per {@link de.blight.common.model.trigger.ChangeRoutineTrigger} gesetzt.
*/
private String activeRoutineName;
// ── Routine-Hilfsmethoden ────────────────────────────────────────────────
/**
* Liefert die aktive Routine: die mit {@link #activeRoutineName} oder,
* als Fallback, die erste in der Liste.
*/
public NpcRoutine getActiveRoutine() {
if (routines == null || routines.isEmpty()) return null;
if (activeRoutineName != null) {
for (NpcRoutine r : routines)
if (activeRoutineName.equals(r.getName())) return r;
}
return routines.get(0);
}
/** Setzt die aktive Routine per Objekt. */
public void setCurrentRoutine(NpcRoutine routine) {
this.activeRoutineName = (routine != null) ? routine.getName() : null;
}
/** Setzt die aktive Routine direkt per Name (Kurzform für Trigger-Laufzeit). */
public void setCurrentRoutine(String routineName) {
this.activeRoutineName = routineName;
}
// ── Dialog-Methoden ──────────────────────────────────────────────────────
public List<DialogOption> getAvailableOptions(MainCharacter character) {
return currentOptions.stream().filter(option ->
return currentOptions.stream().filter(option ->
option.getRequiresChapter() < character.getChapter() &&
option.getRequiresStatus().requirementFulfilled(status) &&
(option.getRequiresQuestOpen() == null || character.getOpenQuests().contains(option.getRequiresQuestOpen())) &&
(option.getRequiredItem() == null || character.getInventar().hasItem(option.getRequiredItem())) &&
(option.getRequiresQuestComplete() == null || character.getCompletedQuests().contains(option.getRequiresQuestComplete()))).toList();
}
public boolean chooseDialogOption(DialogOption option, MainCharacter character) {
if (!currentOptions.contains(option)) {
LOG.warn("Dialog Option was choosen but is not available");
LOG.warn("Dialog Option was choosen but is not available");
return false;
}
if (option.getRequiredItem() != null && !character.getInventar().hasItem(option.getRequiredItem())) {
LOG.warn("Dialog Option was choosen but required item is not in Inventar");
return false;
LOG.warn("Dialog Option was choosen but required item is not in Inventar");
return false;
}
if (option.getRequiresQuestOpen() != null && !character.getOpenQuests().contains(option.getRequiresQuestOpen())) {
LOG.warn("Dialog Option was choosen but required quest is not open");
LOG.warn("Dialog Option was choosen but required quest is not open");
return false;
}
if (option.getRequiredStatus().requirementFulfilled(status)) {
LOG.warn("Dialog Option was choosen but required Status is not fulfilled");
LOG.warn("Dialog Option was choosen but required Status is not fulfilled");
return false;
}
if (option.getRequiresQuestComplete() != null && !character.getCompletedQuests().contains(option.getRequiresQuestComplete())) {
LOG.warn("Dialog Option was choosen but required Quest is not complete");
LOG.warn("Dialog Option was choosen but required Quest is not complete");
return false;
}
currentOptions.remove(option);
currentOptions.removeAll(option.getDisablesOptions());
currentOptions.addAll(option.getNextOptions());
character.handleDialogOption(option);
if (option.isEnablesTrade()) {
this.trader = true;
}
return true;
}
}

View File

@@ -0,0 +1,67 @@
package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
/**
* Ein benannter 24-Stunden-Tagesablauf für einen NPC.
*
* Mehrere Routinen können pro NPC definiert werden; welche aktiv ist,
* bestimmt die Spielmechanik zur Laufzeit.
*
* Gültigkeit: Die Blöcke müssen zusammen alle 24 Stunden abdecken
* (ohne Lücken, ohne Überlappung). Dies wird durch {@link #validate()}
* geprüft.
*/
public class NpcRoutine {
private String name;
private List<RoutineBlock> blocks = new ArrayList<>();
public NpcRoutine() { this.name = "Routine"; }
public NpcRoutine(String name) { this.name = name; }
// ── Getter / Setter ──────────────────────────────────────────────────────
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public List<RoutineBlock> getBlocks() {
if (blocks == null) blocks = new ArrayList<>();
return blocks;
}
public void setBlocks(List<RoutineBlock> b) { this.blocks = b; }
/**
* Prüft ob alle 24 Stunden abgedeckt sind (keine Lücken, keine Überlappungen).
*
* @return null wenn gültig, sonst Fehlermeldung
*/
public String validate() {
if (blocks == null) return "Keine Blöcke definiert.";
boolean[] covered = new boolean[24];
for (RoutineBlock b : blocks) {
for (int h = 0; h < 24; h++) {
if (!b.covers(h)) continue;
if (covered[h]) return "Stunde " + h + ":00 ist mehrfach belegt.";
covered[h] = true;
}
}
for (int h = 0; h < 24; h++) {
if (!covered[h]) return "Stunde " + h + ":00 ist nicht belegt.";
}
return null;
}
/** Anzahl abgedeckter Stunden (0-24). */
public int coveredHours() {
if (blocks == null) return 0;
boolean[] covered = new boolean[24];
for (RoutineBlock b : blocks)
for (int h = 0; h < 24; h++)
if (b.covers(h)) covered[h] = true;
int count = 0;
for (boolean c : covered) if (c) count++;
return count;
}
}

View File

@@ -0,0 +1,133 @@
package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
/**
* Beschreibt was ein NPC zu einem bestimmten Zeitblock tut.
* Alle Felder ausser {@code type} sind optional welche genutzt werden
* hängt vom Typ ab.
*/
public class RoutineActivity {
public enum Type {
/** Sitzen an einem Interactable oder Bodenpunkt. */
SIT,
/** Stehen an einem Weltpunkt. */
STAND,
/** Gespräch mit einem anderen NPC an einem Weltpunkt. */
TALK,
/** Patrouille entlang mehrerer Wegpunkte. */
PATROL,
/** Arbeiten an einem Interactable. */
WORK,
/** Schlafen an einem Interactable. */
SLEEP
}
private Type type;
/** Bodenpunkt (SIT-Boden, STAND, TALK). */
private WorldPoint position;
/** UUID eines platzierten Objekts (SIT-Interactable, WORK, SLEEP). */
private String objectUuid;
/** Anzeigename / Beschreibung des Objekts nur für UI, nicht normativ. */
private String objectLabel;
/** Ziel-NPC-ID für TALK. */
private String talkNpcId;
/** Wegpunkte für PATROL. */
private List<WorldPoint> waypoints;
// ── Factory-Methoden ─────────────────────────────────────────────────────
public static RoutineActivity sit(WorldPoint groundPos) {
RoutineActivity a = new RoutineActivity();
a.type = Type.SIT;
a.position = groundPos;
return a;
}
public static RoutineActivity sitInteractable(String uuid, String label) {
RoutineActivity a = new RoutineActivity();
a.type = Type.SIT;
a.objectUuid = uuid;
a.objectLabel = label;
return a;
}
public static RoutineActivity stand(WorldPoint pos) {
RoutineActivity a = new RoutineActivity();
a.type = Type.STAND;
a.position = pos;
return a;
}
public static RoutineActivity talk(WorldPoint pos, String npcId) {
RoutineActivity a = new RoutineActivity();
a.type = Type.TALK;
a.position = pos;
a.talkNpcId = npcId;
return a;
}
public static RoutineActivity patrol(List<WorldPoint> pts) {
RoutineActivity a = new RoutineActivity();
a.type = Type.PATROL;
a.waypoints = new ArrayList<>(pts);
return a;
}
public static RoutineActivity work(String uuid, String label) {
RoutineActivity a = new RoutineActivity();
a.type = Type.WORK;
a.objectUuid = uuid;
a.objectLabel = label;
return a;
}
public static RoutineActivity sleep(String uuid, String label) {
RoutineActivity a = new RoutineActivity();
a.type = Type.SLEEP;
a.objectUuid = uuid;
a.objectLabel = label;
return a;
}
// ── Getter / Setter ──────────────────────────────────────────────────────
public Type getType() { return type; }
public void setType(Type t) { this.type = t; }
public WorldPoint getPosition() { return position; }
public void setPosition(WorldPoint p) { this.position = p; }
public String getObjectUuid() { return objectUuid; }
public void setObjectUuid(String uuid) { this.objectUuid = uuid; }
public String getObjectLabel() { return objectLabel; }
public void setObjectLabel(String l) { this.objectLabel = l; }
public String getTalkNpcId() { return talkNpcId; }
public void setTalkNpcId(String id) { this.talkNpcId = id; }
public List<WorldPoint> getWaypoints() { return waypoints; }
public void setWaypoints(List<WorldPoint> wp) { this.waypoints = wp; }
/** Menschenlesbare Kurzdarstellung für Listen-Einträge. */
public String summary() {
if (type == null) return "";
return switch (type) {
case SIT -> objectUuid != null ? "Sitzen @ " + shortLabel() : "Sitzen " + posStr();
case STAND -> "Stehen " + posStr();
case TALK -> "Reden mit " + (talkNpcId != null ? talkNpcId : "?") + " " + posStr();
case PATROL -> "Patrouille (" + (waypoints != null ? waypoints.size() : 0) + " Punkte)";
case WORK -> "Arbeiten @ " + shortLabel();
case SLEEP -> "Schlafen @ " + shortLabel();
};
}
private String posStr() {
return position != null ? position.toString() : "(?)";
}
private String shortLabel() {
if (objectLabel != null && !objectLabel.isBlank()) return objectLabel;
if (objectUuid != null && objectUuid.length() >= 8)
return objectUuid.substring(0, 8) + "";
return "?";
}
}

View File

@@ -0,0 +1,56 @@
package de.blight.common.model;
/**
* Zeitblock innerhalb eines Tagesablaufs.
*
* {@code startHour} und {@code endHour} sind ganzzahlige Stunden (023).
* Ein Block, der über Mitternacht geht (z.B. 2206), ist erlaubt:
* in diesem Fall ist {@code endHour < startHour}.
*
* Die Dauer berechnet sich als:
* endHour >= startHour → endHour - startHour
* endHour < startHour → 24 - startHour + endHour
*/
public class RoutineBlock {
private int startHour; // 0-23
private int endHour; // 0-23 (exklusiv: Block endet um endHour:00)
private RoutineActivity activity;
public RoutineBlock() {}
public RoutineBlock(int startHour, int endHour, RoutineActivity activity) {
this.startHour = startHour;
this.endHour = endHour;
this.activity = activity;
}
// ── Getter / Setter ──────────────────────────────────────────────────────
public int getStartHour() { return startHour; }
public void setStartHour(int h) { this.startHour = h; }
public int getEndHour() { return endHour; }
public void setEndHour(int h) { this.endHour = h; }
public RoutineActivity getActivity() { return activity; }
public void setActivity(RoutineActivity a) { this.activity = a; }
/** Anzahl der durch diesen Block abgedeckten Stunden. */
public int durationHours() {
if (endHour > startHour) return endHour - startHour;
if (endHour < startHour) return 24 - startHour + endHour;
return 0;
}
/** Gibt true wenn die gegebene Stunde (0-23) in diesem Block liegt. */
public boolean covers(int hour) {
if (endHour > startHour) return hour >= startHour && hour < endHour;
// Über-Mitternacht-Block
return hour >= startHour || hour < endHour;
}
/** Formatiert den Block als "HH:00 HH:00 | Aktivität". */
public String displayLabel() {
String act = activity != null ? activity.summary() : "";
return String.format("%02d:00 %02d:00 %s", startHour, endHour, act);
}
}

View File

@@ -32,6 +32,18 @@ public final class TextRegistry {
return entries.getOrDefault(ref.id(), fallback);
}
/**
* Löst zuerst {@code ref} auf; fehlt die Ref, wird {@code key} direkt nachgeschlagen;
* fehlt auch der Eintrag für {@code key}, wird {@code fallback} zurückgegeben.
*/
public static String resolve(TextReference ref, String key, String fallback) {
if (ref != null && ref.id() != null && entries.containsKey(ref.id()))
return entries.get(ref.id());
if (key != null && entries.containsKey(key))
return entries.get(key);
return fallback;
}
/** Direkter Zugriff für den Editor (alle Einträge). */
public static Map<String, String> getAll() {
return new HashMap<>(entries);

View File

@@ -0,0 +1,17 @@
package de.blight.common.model;
/** Weltposition (x, y, z in Metern). y = 0 bedeutet "Terrain-Höhe verwenden". */
public class WorldPoint {
public float x, y, z;
public WorldPoint() {}
public WorldPoint(float x, float y, float z) {
this.x = x; this.y = y; this.z = z;
}
@Override
public String toString() {
return String.format("(%.1f, %.1f, %.1f)", x, y, z);
}
}

View File

@@ -0,0 +1,33 @@
package de.blight.common.model.trigger;
import de.blight.common.model.MainCharacter;
import lombok.Getter;
import lombok.Setter;
/**
* Ändert den aktiven Tagesablauf eines NPCs.
*
* Die eigentliche NPC-Suche zur Laufzeit obliegt der Game-Registry.
* Gespeichert werden nur IDs, keine Objektreferenzen.
*/
@Getter
@Setter
public class ChangeRoutineTrigger extends Trigger {
/** Character-ID des NPCs, dessen Routine geändert werden soll. */
private String npcId;
/** Name der Routine, die aktiviert werden soll. */
private String routineName;
@Override
public boolean isTriggarableDelegate(MainCharacter character) {
return npcId != null && !npcId.isBlank()
&& routineName != null && !routineName.isBlank();
}
@Override
public void trigger(MainCharacter character) {
// Laufzeit: NPC per ID aus der Game-Registry holen und
// npc.setCurrentRoutine(routineName) aufrufen.
}
}

View File

@@ -27,6 +27,7 @@ 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";
public static final String TYPE_CHANGE_ROUTINE = "CHANGE_ROUTINE";
private static final Gson GSON = new GsonBuilder()
.registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter())
@@ -78,6 +79,9 @@ public final class TriggerIO {
obj.addProperty("fractionId", f.getFractionId().toString());
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.getRoutineName() != null) obj.addProperty("routineName", r.getRoutineName());
}
return obj;
}
@@ -113,6 +117,12 @@ public final class TriggerIO {
if (obj.has("targetStatus")) f.setTargetStatus(parseStatus(obj.get("targetStatus")));
yield f;
}
case TYPE_CHANGE_ROUTINE -> {
ChangeRoutineTrigger r = new ChangeRoutineTrigger();
if (obj.has("npcId")) r.setNpcId(obj.get("npcId").getAsString());
if (obj.has("routineName")) r.setRoutineName(obj.get("routineName").getAsString());
yield r;
}
default -> null;
};
@@ -125,6 +135,7 @@ public final class TriggerIO {
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;
return "UNKNOWN";
}

View File

@@ -0,0 +1,32 @@
package de.blight.common.path;
/** Ungerichtete Kante zwischen zwei Knoten des Wegnetzes. */
public class PathEdge {
private final String nodeUuidA;
private final String nodeUuidB;
public PathEdge(String nodeUuidA, String nodeUuidB) {
this.nodeUuidA = nodeUuidA;
this.nodeUuidB = nodeUuidB;
}
public String getNodeUuidA() { return nodeUuidA; }
public String getNodeUuidB() { return nodeUuidB; }
public boolean connects(String uuidA, String uuidB) {
return (nodeUuidA.equals(uuidA) && nodeUuidB.equals(uuidB))
|| (nodeUuidA.equals(uuidB) && nodeUuidB.equals(uuidA));
}
public boolean involves(String uuid) {
return nodeUuidA.equals(uuid) || nodeUuidB.equals(uuid);
}
/** Gibt den jeweils anderen Endpunkt zurück, oder null wenn uuid nicht enthalten. */
public String other(String uuid) {
if (nodeUuidA.equals(uuid)) return nodeUuidB;
if (nodeUuidB.equals(uuid)) return nodeUuidA;
return null;
}
}

View File

@@ -0,0 +1,227 @@
package de.blight.common.path;
import de.blight.common.model.WorldPoint;
import java.util.*;
/**
* Wegnetz bestehend aus Knoten ({@link PathNode}) und Kanten ({@link PathEdge}).
*
* <p>Pathfinding-Modell:
* <ol>
* <li>Nächsten Netzknoten zu {@code from} suchen → Off-Netz-Segment 1</li>
* <li>A* entlang der Kanten zum Netzknoten nächst {@code to} → Netz-Segment</li>
* <li>Off-Netz-Segment 2: letzter Netzknoten → {@code to}</li>
* </ol>
* Die Hindernisvermeidung auf Segmenten 1 und 3 obliegt dem Bewegungssystem
* der Game-Engine (z. B. Steering-Behaviors + Raycasting).
*/
public class PathNetwork {
private final List<PathNode> nodes = new ArrayList<>();
private final List<PathEdge> edges = new ArrayList<>();
// ── Nodes ──────────────────────────────────────────────────────────────────
public List<PathNode> getNodes() { return nodes; }
public List<PathEdge> getEdges() { return edges; }
public PathNode nodeById(String uuid) {
for (PathNode n : nodes)
if (n.getUuid().equals(uuid)) return n;
return null;
}
public void addNode(PathNode node) {
nodes.add(node);
}
public void removeNode(String uuid) {
nodes.removeIf(n -> n.getUuid().equals(uuid));
edges.removeIf(e -> e.involves(uuid));
}
// ── Edges ──────────────────────────────────────────────────────────────────
/** Fügt eine Kante ein. Duplikate (gleiche Knotenpaare) werden ignoriert. */
public boolean addEdge(String uuidA, String uuidB) {
if (uuidA.equals(uuidB)) return false;
for (PathEdge e : edges)
if (e.connects(uuidA, uuidB)) return false;
edges.add(new PathEdge(uuidA, uuidB));
return true;
}
public void removeEdge(String uuidA, String uuidB) {
edges.removeIf(e -> e.connects(uuidA, uuidB));
}
public List<PathNode> neighbors(String uuid) {
List<PathNode> result = new ArrayList<>();
for (PathEdge e : edges) {
String other = e.other(uuid);
if (other != null) {
PathNode n = nodeById(other);
if (n != null) result.add(n);
}
}
return result;
}
// ── Nächster Knoten ────────────────────────────────────────────────────────
/**
* Liefert den Knoten mit der geringsten horizontalen (X/Z) Distanz zu
* {@code point}, oder {@code null} wenn das Netz leer ist.
*/
public PathNode nearestNode(WorldPoint point) {
PathNode best = null;
float bestDist = Float.MAX_VALUE;
for (PathNode n : nodes) {
float d = n.dist2D(point);
if (d < bestDist) { bestDist = d; best = n; }
}
return best;
}
// ── Pathfinding ────────────────────────────────────────────────────────────
/**
* Berechnet den vollständigen Pfad von {@code from} nach {@code to}.
*
* <p>Rückgabe: geordnete Liste von Weltpunkten, die der NPC abläuft:
* [from, ...Netzknoten..., to].
*
* <p>Wenn das Netz leer ist, wird [from, to] zurückgegeben (direkter Weg).
*/
public List<WorldPoint> findPath(WorldPoint from, WorldPoint to) {
List<WorldPoint> result = new ArrayList<>();
result.add(from);
if (nodes.isEmpty()) {
result.add(to);
return result;
}
PathNode startNode = nearestNode(from);
PathNode endNode = nearestNode(to);
if (startNode == endNode) {
// Start und Ziel sind am gleichen Netzpunkt
result.add(startNode.getPosition());
result.add(to);
return result;
}
List<PathNode> networkPath = astar(startNode, endNode);
for (PathNode n : networkPath)
result.add(n.getPosition());
result.add(to);
return result;
}
/**
* Wie {@link #findPath}, gibt aber die drei Segmente getrennt zurück.
* Nützlich für die Game-Engine, die Off-Netz- und Netz-Bewegung
* unterschiedlich behandelt.
*/
public PathResult findPathSegmented(WorldPoint from, WorldPoint to) {
if (nodes.isEmpty()) {
return new PathResult(List.of(from), List.of(), List.of(to));
}
PathNode startNode = nearestNode(from);
PathNode endNode = nearestNode(to);
List<WorldPoint> offStart = List.of(from, startNode.getPosition());
List<WorldPoint> offEnd = List.of(endNode.getPosition(), to);
if (startNode == endNode) {
return new PathResult(offStart, List.of(startNode.getPosition()), offEnd);
}
List<WorldPoint> networkPts = new ArrayList<>();
for (PathNode n : astar(startNode, endNode))
networkPts.add(n.getPosition());
return new PathResult(offStart, networkPts, offEnd);
}
// ── A* ─────────────────────────────────────────────────────────────────────
private List<PathNode> astar(PathNode start, PathNode goal) {
Map<String, Float> gScore = new HashMap<>();
Map<String, Float> fScore = new HashMap<>();
Map<String, PathNode> cameFrom = new HashMap<>();
PriorityQueue<PathNode> open = new PriorityQueue<>(
Comparator.comparingDouble(n -> fScore.getOrDefault(n.getUuid(), Float.MAX_VALUE)));
gScore.put(start.getUuid(), 0f);
fScore.put(start.getUuid(), start.dist3D(goal));
open.add(start);
while (!open.isEmpty()) {
PathNode current = open.poll();
if (current.getUuid().equals(goal.getUuid()))
return reconstructPath(cameFrom, current);
for (PathNode neighbor : neighbors(current.getUuid())) {
float tentativeG = gScore.getOrDefault(current.getUuid(), Float.MAX_VALUE)
+ current.dist3D(neighbor);
if (tentativeG < gScore.getOrDefault(neighbor.getUuid(), Float.MAX_VALUE)) {
cameFrom.put(neighbor.getUuid(), current);
gScore.put(neighbor.getUuid(), tentativeG);
fScore.put(neighbor.getUuid(), tentativeG + neighbor.dist3D(goal));
open.remove(neighbor);
open.add(neighbor);
}
}
}
// Kein Pfad gefunden direkte Verbindung start→goal als Fallback
return List.of(start, goal);
}
private List<PathNode> reconstructPath(Map<String, PathNode> cameFrom, PathNode current) {
Deque<PathNode> path = new ArrayDeque<>();
path.addFirst(current);
while (cameFrom.containsKey(current.getUuid())) {
current = cameFrom.get(current.getUuid());
path.addFirst(current);
}
return new ArrayList<>(path);
}
// ── Ergebnis-Record ────────────────────────────────────────────────────────
/**
* Dreisegmentiger Pfad:
* <ul>
* <li>{@link #offNetworkStart}: from → erster Netzknoten (Hindernisvermeidung per Steering)</li>
* <li>{@link #networkPath}: Knotenfolge auf dem Wegnetz (A*)</li>
* <li>{@link #offNetworkEnd}: letzter Netzknoten → to (Hindernisvermeidung per Steering)</li>
* </ul>
*/
public record PathResult(
List<WorldPoint> offNetworkStart,
List<WorldPoint> networkPath,
List<WorldPoint> offNetworkEnd
) {
/** Alle Punkte als flache Liste (für einfache Agenten). */
public List<WorldPoint> flatten() {
List<WorldPoint> all = new ArrayList<>();
all.addAll(offNetworkStart);
// Ersten Netzpunkt nicht doppeln (ist bereits letzter Punkt von offNetworkStart)
for (int i = 1; i < networkPath.size(); i++) all.add(networkPath.get(i));
// Letzten Netzpunkt nicht doppeln (ist bereits erster Punkt von offNetworkEnd)
if (!offNetworkEnd.isEmpty() && !networkPath.isEmpty())
for (int i = 1; i < offNetworkEnd.size(); i++) all.add(offNetworkEnd.get(i));
else all.addAll(offNetworkEnd);
return all;
}
}
}

View File

@@ -0,0 +1,85 @@
package de.blight.common.path;
import de.blight.common.MapIO;
import de.blight.common.model.WorldPoint;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.Locale;
/**
* Liest und schreibt das Wegnetz.
*
* Datei: {@code blight_paths.blp} neben {@code blight_map.blm}.
*
* Format (TSV):
* <pre>
* # Blight Path Network
* NODE uuid name x y z
* EDGE uuid1 uuid2
* </pre>
* Leerzeilen und Zeilen die mit # beginnen werden ignoriert.
* Name-Feld darf leer sein, wird dann als "-" gespeichert.
*/
public final class PathNetworkIO {
private PathNetworkIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_paths.blp");
}
public static void save(PathNetwork net) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p, StandardCharsets.UTF_8)) {
w.write("# Blight Path Network"); w.newLine();
w.write("# NODE uuid name x y z"); w.newLine();
w.write("# EDGE uuid1 uuid2"); w.newLine();
for (PathNode n : net.getNodes()) {
String name = (n.getName() == null || n.getName().isBlank()) ? "-" : n.getName();
w.write(String.format(Locale.ROOT, "NODE\t%s\t%s\t%.5f\t%.5f\t%.5f%n",
n.getUuid(), name,
n.getPosition().x, n.getPosition().y, n.getPosition().z));
}
for (PathEdge e : net.getEdges()) {
w.write(String.format("EDGE\t%s\t%s%n", e.getNodeUuidA(), e.getNodeUuidB()));
}
}
}
public static PathNetwork load() throws IOException {
PathNetwork net = new PathNetwork();
Path p = getPath();
if (!Files.exists(p)) return net;
for (String line : Files.readAllLines(p, StandardCharsets.UTF_8)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length == 0) continue;
try {
switch (f[0]) {
case "NODE" -> {
if (f.length < 6) break;
String uuid = f[1];
String name = "-".equals(f[2]) ? "" : f[2];
float x = Float.parseFloat(f[3]);
float y = Float.parseFloat(f[4]);
float z = Float.parseFloat(f[5]);
net.addNode(new PathNode(uuid, name, new WorldPoint(x, y, z)));
}
case "EDGE" -> {
if (f.length < 3) break;
net.addEdge(f[1], f[2]);
}
}
} catch (NumberFormatException ignored) {}
}
return net;
}
}

View File

@@ -0,0 +1,48 @@
package de.blight.common.path;
import de.blight.common.model.WorldPoint;
import java.util.UUID;
/** Einzelner Knoten im Wegnetz. Position wird auf Terrain-Höhe gesnapped. */
public class PathNode {
private final String uuid;
private String name;
private WorldPoint position;
public PathNode(WorldPoint position) {
this(UUID.randomUUID().toString(), "", position);
}
public PathNode(String uuid, String name, WorldPoint position) {
this.uuid = uuid;
this.name = name;
this.position = position;
}
public String getUuid() { return uuid; }
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public WorldPoint getPosition() { return position; }
public void setPosition(WorldPoint p) { this.position = p; }
/** Horizontale Distanz (X/Z) zu einem anderen Punkt. */
public float dist2D(WorldPoint other) {
float dx = position.x - other.x;
float dz = position.z - other.z;
return (float) Math.sqrt(dx * dx + dz * dz);
}
/** 3D-Distanz zum anderen Knoten. */
public float dist3D(PathNode other) {
float dx = position.x - other.position.x;
float dy = position.y - other.position.y;
float dz = position.z - other.position.z;
return (float) Math.sqrt(dx * dx + dy * dy + dz * dz);
}
@Override public String toString() {
return name.isBlank() ? uuid.substring(0, 8) + "" : name;
}
}