Weiter am Editor gearbeitet, unter anderem LOD System, Items, Trees, Modelle

This commit is contained in:
2026-06-08 22:25:47 +02:00
parent 1297869dfa
commit 5e85051716
1113 changed files with 3665 additions and 529 deletions

View File

@@ -0,0 +1,254 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.zip.*;
/**
* Konstanten, Datei-I/O und Hilfsmethoden für das chunk-basierte Terrain-System.
*
* Die Welt (4096 × 4096 m) wird in CHUNKS_PER_AXIS² = 32² = 1024 quadratische
* Chunks à CHUNK_SIZE = 128 m unterteilt. Jeder Chunk speichert CHUNK_VERTS² = 129²
* Höhenwerte bei 1 m Auflösung (native Editor-Auflösung).
*
* LOD-Stufen (3):
* LOD 0 1 m/Vertex (129×129) nah, Chebyshev-Dist ≤ LOD0_RANGE
* LOD 1 4 m/Vertex (33×33) mittel, Chebyshev-Dist ≤ LOD1_RANGE
* LOD 2 16 m/Vertex (9×9) fern, Rest
*
* Kanten-IDs (EDGE_*): werden für Seam-Stitching verwendet.
*/
public final class ChunkTerrainIO {
// ── Welt / Chunk-Konfiguration ─────────────────────────────────────────────
public static final int CHUNK_SIZE = 128;
public static final int WORLD_SIZE = 4096;
public static final int CHUNKS_PER_AXIS = WORLD_SIZE / CHUNK_SIZE; // 32
public static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; // 1024
/** Vertices pro Kante bei nativer 1-m-Auflösung (inklusiv). */
public static final int CHUNK_VERTS = CHUNK_SIZE + 1; // 129
// ── LOD-Konfiguration ──────────────────────────────────────────────────────
public static final int LOD_COUNT = 3;
/** Meter pro Vertex je LOD-Stufe. */
public static final float[] LOD_SPACING = { 1f, 4f, 16f };
/** Vertices pro Kante je LOD-Stufe. */
public static final int[] LOD_VERTS = { 129, 33, 9 };
/** Chebyshev-Distanz (in Chunks) bis inkl. dieser Stufe aktiv ist. */
public static final int LOD0_RANGE = 1;
public static final int LOD1_RANGE = 3;
/** Chebyshev-Distanz ≤ PHYSICS_RANGE: Physik-Collider aktiv. */
public static final int PHYSICS_RANGE = 1;
// ── Kanten-Konstanten ──────────────────────────────────────────────────────
public static final int EDGE_NORTH = 0; // höchste Zeile (row = VERTS-1)
public static final int EDGE_SOUTH = 1; // unterste Zeile (row = 0)
public static final int EDGE_EAST = 2; // rechteste Spalte (col = VERTS-1)
public static final int EDGE_WEST = 3; // linkeste Spalte (col = 0)
private static final int MAGIC = 0x424C4354; // "BLCT"
private static final int VERSION = 1;
private ChunkTerrainIO() {}
// ── Dateipfade ─────────────────────────────────────────────────────────────
public static Path chunksDir() {
return MapIO.getMapPath().resolveSibling("chunks");
}
public static Path getChunkPath(int cx, int cz) {
return chunksDir().resolve(String.format("chunk_%02d_%02d.blc", cx, cz));
}
public static boolean chunkExists(int cx, int cz) {
return Files.exists(getChunkPath(cx, cz));
}
public static boolean allChunksExist() {
for (int cz = 0; cz < CHUNKS_PER_AXIS; cz++)
for (int cx = 0; cx < CHUNKS_PER_AXIS; cx++)
if (!chunkExists(cx, cz)) return false;
return true;
}
// ── I/O ───────────────────────────────────────────────────────────────────
public static void saveChunk(int cx, int cz, float[] heights) throws IOException {
if (heights.length != CHUNK_VERTS * CHUNK_VERTS)
throw new IllegalArgumentException("heights.length muss " + (CHUNK_VERTS * CHUNK_VERTS) + " sein");
Path p = getChunkPath(cx, cz);
Path tmp = p.resolveSibling(p.getFileName() + ".tmp");
Files.createDirectories(p.getParent());
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new GZIPOutputStream(Files.newOutputStream(tmp))))) {
out.writeInt(MAGIC);
out.writeInt(VERSION);
out.writeInt(cx);
out.writeInt(cz);
for (float h : heights) out.writeFloat(h);
}
try {
Files.move(tmp, p, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, p, StandardCopyOption.REPLACE_EXISTING);
}
}
public static float[] loadChunk(int cx, int cz) throws IOException {
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new GZIPInputStream(
Files.newInputStream(getChunkPath(cx, cz)))))) {
if (in.readInt() != MAGIC) throw new IOException("Ungültiges Chunk-Format");
int ver = in.readInt();
if (ver != VERSION) throw new IOException("Unbekannte Chunk-Version: " + ver);
in.readInt(); // cx (ignoriert, Dateiname ist maßgeblich)
in.readInt(); // cz
float[] h = new float[CHUNK_VERTS * CHUNK_VERTS];
for (int i = 0; i < h.length; i++) h[i] = in.readFloat();
return h;
}
}
// ── Export ────────────────────────────────────────────────────────────────
/**
* Teilt die Editor-Heightmap (4097 × 4097, 1 m/Vertex) in 1024 Chunk-Dateien auf.
* Direkte Kopie ohne Interpolation jeder Chunk enthält exakt die Editor-Vertices
* seines Bereichs.
*/
public static void exportFromEditorHeightMap(float[] editorH, int editorVerts) throws IOException {
for (int cz = 0; cz < CHUNKS_PER_AXIS; cz++) {
for (int cx = 0; cx < CHUNKS_PER_AXIS; cx++) {
float[] chunk = new float[CHUNK_VERTS * CHUNK_VERTS];
for (int row = 0; row < CHUNK_VERTS; row++) {
int srcRow = cz * CHUNK_SIZE + row;
for (int col = 0; col < CHUNK_VERTS; col++) {
chunk[row * CHUNK_VERTS + col] = editorH[srcRow * editorVerts + cx * CHUNK_SIZE + col];
}
}
saveChunk(cx, cz, chunk);
}
}
}
/**
* Migriert eine alte MapData (16385 × 16385 Höhenwerte, 0,25 m/Vertex)
* in das Chunk-Format. Jeder Chunk enthält 129 × 129 Werte (jeder 4. Quell-Vertex).
*/
public static void exportFromMapData(MapData data) throws IOException {
int srcVerts = MapData.TERRAIN_VERTS; // 16385
int srcPerChunk = (srcVerts - 1) / CHUNKS_PER_AXIS; // 512
int step = srcPerChunk / (CHUNK_VERTS - 1); // 4
for (int cz = 0; cz < CHUNKS_PER_AXIS; cz++) {
for (int cx = 0; cx < CHUNKS_PER_AXIS; cx++) {
float[] chunk = new float[CHUNK_VERTS * CHUNK_VERTS];
for (int row = 0; row < CHUNK_VERTS; row++) {
int srcRow = Math.min(cz * srcPerChunk + row * step, srcVerts - 1);
for (int col = 0; col < CHUNK_VERTS; col++) {
int srcCol = Math.min(cx * srcPerChunk + col * step, srcVerts - 1);
chunk[row * CHUNK_VERTS + col] = data.terrainHeight[srcRow * srcVerts + srcCol];
}
}
saveChunk(cx, cz, chunk);
}
}
}
/** Erzeugt leere (flache) Chunk-Dateien mit Höhe 1.0 für alle 1024 Chunks. */
public static void exportBlankChunks() throws IOException {
float[] flat = new float[CHUNK_VERTS * CHUNK_VERTS];
java.util.Arrays.fill(flat, 1f);
for (int cz = 0; cz < CHUNKS_PER_AXIS; cz++)
for (int cx = 0; cx < CHUNKS_PER_AXIS; cx++)
saveChunk(cx, cz, flat);
}
// ── Downsample ────────────────────────────────────────────────────────────
/** Bilineare Verkleinerung eines quadratischen srcVerts²-Arrays auf dstVerts². */
public static float[] downsample(float[] src, int srcVerts, int dstVerts) {
if (srcVerts == dstVerts) return src.clone();
float[] dst = new float[dstVerts * dstVerts];
float step = (float) (srcVerts - 1) / (dstVerts - 1);
for (int dz = 0; dz < dstVerts; dz++) {
float sz = dz * step;
int sz0 = Math.min((int) sz, srcVerts - 2), sz1 = sz0 + 1;
float fz = sz - sz0;
for (int dx = 0; dx < dstVerts; dx++) {
float sx = dx * step;
int sx0 = Math.min((int) sx, srcVerts - 2), sx1 = sx0 + 1;
float fx = sx - sx0;
dst[dz * dstVerts + dx] =
(src[sz0 * srcVerts + sx0] * (1 - fx) + src[sz0 * srcVerts + sx1] * fx) * (1 - fz)
+ (src[sz1 * srcVerts + sx0] * (1 - fx) + src[sz1 * srcVerts + sx1] * fx) * fz;
}
}
return dst;
}
// ── Seam-Stitching ────────────────────────────────────────────────────────
/**
* Extrahiert den Rand eines quadratischen height-Arrays (verts × verts).
* Werte in Leserichtung: West→Ost für NORTH/SOUTH, Nord→Süd für EAST/WEST.
*/
public static float[] extractEdge(float[] heights, int verts, int edge) {
float[] e = new float[verts];
switch (edge) {
case EDGE_NORTH -> { for (int i = 0; i < verts; i++) e[i] = heights[(verts - 1) * verts + i]; }
case EDGE_SOUTH -> { System.arraycopy(heights, 0, e, 0, verts); }
case EDGE_EAST -> { for (int i = 0; i < verts; i++) e[i] = heights[i * verts + (verts - 1)]; }
case EDGE_WEST -> { for (int i = 0; i < verts; i++) e[i] = heights[i * verts]; }
}
return e;
}
/**
* Passt die Randwerte in {@code heights} (fineVerts × fineVerts) an die gröbere
* Nachbar-Kante an: nicht-ausgerichtete Zwischenvertices werden auf die lineare
* Interpolation der Anker-Vertices gesetzt → eliminiert Höhenrisse an LOD-Grenzen.
*
* @param heights height-Array des feinen Chunks (in-place modifiziert)
* @param fineVerts Vertices pro Kante (fein)
* @param coarseEdge Randwerte des groben Nachbarn (Länge coarseVerts)
* @param coarseVerts Länge von coarseEdge
* @param edge welche Kante dieses Chunks (EDGE_*)
*/
public static void stitchEdge(float[] heights, int fineVerts,
float[] coarseEdge, int coarseVerts, int edge) {
int ratio = (fineVerts - 1) / (coarseVerts - 1);
for (int i = 0; i < fineVerts; i++) {
if (i % ratio == 0) continue;
int k0 = i / ratio;
float t = (float) (i % ratio) / ratio;
float snapped = coarseEdge[k0] + (coarseEdge[k0 + 1] - coarseEdge[k0]) * t;
int idx = switch (edge) {
case EDGE_NORTH -> (fineVerts - 1) * fineVerts + i;
case EDGE_SOUTH -> i;
case EDGE_EAST -> i * fineVerts + (fineVerts - 1);
default -> i * fineVerts; // EDGE_WEST
};
heights[idx] = snapped;
}
}
// ── LOD-Hilfsmethoden ─────────────────────────────────────────────────────
/** Gibt den LOD-Level für eine gegebene Chebyshev-Distanz zurück. */
public static int lodForDistance(int chebyshev) {
if (chebyshev <= LOD0_RANGE) return 0;
if (chebyshev <= LOD1_RANGE) return 1;
return 2;
}
/** Chebyshev-Distanz (in Chunks) zwischen zwei Chunk-Koordinaten. */
public static int chebyshev(int ax, int az, int bx, int bz) {
return Math.max(Math.abs(ax - bx), Math.abs(az - bz));
}
/** Flacher Chunk-Index aus Chunk-Koordinaten. */
public static int chunkIndex(int cx, int cz) {
return cz * CHUNKS_PER_AXIS + cx;
}
}

View File

@@ -18,11 +18,17 @@ public record ModelMeta(
boolean castShadow,
boolean receiveShadow,
float randomScaleMin,
float randomScaleMax
float randomScaleMax,
String lod1Path, // relativer Asset-Pfad; "" = nicht gesetzt
String lod2Path,
float lod1Distance, // ab dieser Distanz LOD1 anzeigen
float lod2Distance, // ab dieser Distanz LOD2 anzeigen
float cullDistance // ab dieser Distanz ausblenden
) {
public static ModelMeta defaults(String j3oFileName) {
String name = j3oFileName.replaceFirst("\\.j3o$", "");
return new ModelMeta(name, "", "", 1f, 1f, 1f, true, 0f, 0f,
false, true, true, 1f, 1f);
false, true, true, 1f, 1f,
"", "", 30f, 80f, 120f);
}
}

View File

@@ -29,6 +29,11 @@ public final class ModelMetaIO {
p.setProperty("receiveShadow", String.valueOf(m.receiveShadow()));
p.setProperty("randomScaleMin", String.valueOf(m.randomScaleMin()));
p.setProperty("randomScaleMax", String.valueOf(m.randomScaleMax()));
p.setProperty("lod1Path", m.lod1Path());
p.setProperty("lod2Path", m.lod2Path());
p.setProperty("lod1Distance", String.valueOf(m.lod1Distance()));
p.setProperty("lod2Distance", String.valueOf(m.lod2Distance()));
p.setProperty("cullDistance", String.valueOf(m.cullDistance()));
try (Writer w = Files.newBufferedWriter(metaPath(j3oPath))) {
p.store(w, null);
}
@@ -57,7 +62,12 @@ public final class ModelMetaIO {
Boolean.parseBoolean(p.getProperty("castShadow", "true")),
Boolean.parseBoolean(p.getProperty("receiveShadow", "true")),
parseFloat(p, "randomScaleMin", 1f),
parseFloat(p, "randomScaleMax", 1f)
parseFloat(p, "randomScaleMax", 1f),
p.getProperty("lod1Path", ""),
p.getProperty("lod2Path", ""),
parseFloat(p, "lod1Distance", 30f),
parseFloat(p, "lod2Distance", 80f),
parseFloat(p, "cullDistance", 120f)
);
}

View File

@@ -0,0 +1,4 @@
package de.blight.common;
/** Ein Item-Pickup das auf der Spielwelt liegt (Kartendaten). */
public record PlacedItem(String itemId, float x, float y, float z) {}

View File

@@ -0,0 +1,51 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Liest und schreibt auf der Karte liegende Items (Pickups).
* Datei: {@code blight_placed_items.bpi} neben {@code blight_map.blm}.
* Format: {@code itemId\tx\ty\tz}
*/
public final class PlacedItemIO {
private PlacedItemIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_placed_items.bpi");
}
public static void save(List<PlacedItem> items) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# itemId\tx\ty\tz");
w.newLine();
for (PlacedItem it : items) {
w.write(String.format(Locale.ROOT, "%s\t%.5f\t%.5f\t%.5f%n",
it.itemId(), it.x(), it.y(), it.z()));
}
}
}
public static List<PlacedItem> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedItem> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 4) continue;
try {
list.add(new PlacedItem(f[0],
Float.parseFloat(f[1]),
Float.parseFloat(f[2]),
Float.parseFloat(f[3])));
} catch (NumberFormatException ignored) {}
}
return list;
}
}

View File

@@ -13,5 +13,10 @@ public record PlacedModel(
/** AnimationLibrary-Clip-Key (z. B. "walk/Run"); "" = keine Animation. */
String animClip,
boolean castShadow,
boolean receiveShadow
boolean receiveShadow,
String lod1Path, // relativer Asset-Pfad; "" = nicht gesetzt
String lod2Path,
float lod1Distance, // ab dieser Distanz LOD1 anzeigen
float lod2Distance, // ab dieser Distanz LOD2 anzeigen
float cullDistance // ab dieser Distanz ausblenden
) {}

View File

@@ -8,10 +8,11 @@ import java.util.*;
* Liest und schreibt platzierte Modelle als tab-separierte Textdatei
* ({@code blight_objects.blo}) neben der Kartendatei.
*
* Spalten (seit v3):
* Spalten (seit v4):
* modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow
* lod1Path lod2Path lod1Distance lod2Distance cullDistance
*
* Alte Dateien mit 6 Spalten (v1/v2) werden gelesen; fehlende Felder erhalten Standardwerte.
* Alte Dateien mit 6 Spalten (v1/v2/v3) werden gelesen; fehlende Felder erhalten Standardwerte.
*/
public final class PlacedModelIO {
@@ -25,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");
w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow\tlod1Path\tlod2Path\tlod1Distance\tlod2Distance\tcullDistance");
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%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%n",
m.modelPath(),
m.x(), m.y(), m.z(),
m.rotY(), m.scale(),
@@ -37,7 +38,9 @@ public final class PlacedModelIO {
m.solid(),
nvl(m.texturePath()), nvl(m.normalMapPath()), nvl(m.materialPath()),
nvl(m.meshFile()), nvl(m.animClip()),
m.castShadow(), m.receiveShadow()));
m.castShadow(), m.receiveShadow(),
nvl(m.lod1Path()), nvl(m.lod2Path()),
m.lod1Distance(), m.lod2Distance(), m.cullDistance()));
}
}
}
@@ -68,14 +71,25 @@ public final class PlacedModelIO {
String animClip = f.length > 13 ? f[13] : "";
boolean castShadow = f.length > 14 ? Boolean.parseBoolean(f[14]) : true;
boolean receiveShadow = f.length > 15 ? Boolean.parseBoolean(f[15]) : true;
String lod1Path = f.length > 16 ? f[16] : "";
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;
list.add(new PlacedModel(modelPath, x, y, z,
rotY, rotX, rotZ, scale, solid,
texPath, nmPath, matPath, meshFile, animClip,
castShadow, receiveShadow));
castShadow, receiveShadow,
lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance));
} catch (NumberFormatException ignored) {}
}
return list;
}
private static String nvl(String s) { return s != null ? s : ""; }
private static float parseFloat(String s, float def) {
try { return Float.parseFloat(s); }
catch (NumberFormatException e) { return def; }
}
}