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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -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 (v1–v4) 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;
|
||||
|
||||
@@ -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.2–0.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
|
||||
) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
100
blight-common/src/main/java/de/blight/common/SculptedMeshIO.java
Normal file
100
blight-common/src/main/java/de/blight/common/SculptedMeshIO.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
43
blight-common/src/main/java/de/blight/common/model/Bed.java
Normal file
43
blight-common/src/main/java/de/blight/common/model/Bed.java
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 "?";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.blight.common.model;
|
||||
|
||||
/**
|
||||
* Zeitblock innerhalb eines Tagesablaufs.
|
||||
*
|
||||
* {@code startHour} und {@code endHour} sind ganzzahlige Stunden (0–23).
|
||||
* Ein Block, der über Mitternacht geht (z.B. 22–06), 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user