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

@@ -12,7 +12,8 @@ public enum AnimationAction {
SPRINT,
JUMP,
RUNNING_JUMP,
DUCK;
DUCK,
PICK_UP;
/** Lesbare Bezeichnung für UI-Anzeige. */
@@ -26,6 +27,7 @@ public enum AnimationAction {
case JUMP -> "Jump";
case RUNNING_JUMP -> "Running Jump";
case DUCK -> "Duck";
case PICK_UP -> "Pick up";
};
}
}

View File

@@ -5,23 +5,25 @@ import com.jme3.input.KeyInput;
/** Speichert alle konfigurierbaren Tastenbelegungen als plain int-Felder (KeyInput-Codes). */
public class KeyBindings {
public int forward = KeyInput.KEY_W;
public int backward = KeyInput.KEY_S;
public int left = KeyInput.KEY_A;
public int right = KeyInput.KEY_D;
public int jump = KeyInput.KEY_SPACE;
public int sprint = KeyInput.KEY_LSHIFT;
public int walk = KeyInput.KEY_LMENU;
public int forward = KeyInput.KEY_W;
public int backward = KeyInput.KEY_S;
public int left = KeyInput.KEY_A;
public int right = KeyInput.KEY_D;
public int jump = KeyInput.KEY_SPACE;
public int sprint = KeyInput.KEY_LSHIFT;
public int walk = KeyInput.KEY_LMENU;
public int interact = KeyInput.KEY_E;
/** Metadaten für die Config-UI: Feldname im Objekt + Anzeigename. */
public static final String[][] ENTRIES = {
{"forward", "Vorwärts"},
{"backward", "Rückwärts"},
{"left", "Links"},
{"right", "Rechts"},
{"jump", "Springen"},
{"sprint", "Rennen"},
{"walk", "Gehen"},
{"forward", "Vorwärts"},
{"backward", "Rückwärts"},
{"left", "Links"},
{"right", "Rechts"},
{"jump", "Springen"},
{"sprint", "Rennen"},
{"walk", "Gehen"},
{"interact", "Interagieren"},
};
public int get(String fieldName) {

View File

@@ -47,6 +47,9 @@ public class PlayerInputControl {
private String runningClip;
/** Frames, für die JUMP erzwungen wird (überbrückt onGround()-Lag). */
private int jumpFrames = 0;
/** Läuft gerade eine Pickup-Animation? */
private boolean pickupActive = false;
private float pickupRemaining = 0f;
private final ActionListener actionListener = (name, isPressed, tpf) -> {
if (paused) return;
@@ -117,9 +120,33 @@ public class PlayerInputControl {
inputManager.addListener(actionListener, ACTION_NAMES);
}
/**
* Spielt die PICK_UP-Animation einmalig ab und blockiert Bewegung für {@code duration} Sekunden.
* Wird von WorldItemsState aufgerufen, sobald der Spieler ein Item aufnimmt.
*/
public void requestPickup(float duration) {
pickupActive = true;
pickupRemaining = duration;
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
playAction(AnimationAction.PICK_UP);
currentAnim = AnimationAction.PICK_UP;
}
public void update(float tpf) {
if (physicsChar == null || paused) return;
if (pickupActive) {
pickupRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO);
if (pickupRemaining <= 0f) {
pickupActive = false;
currentAnim = null; // erzwingt Neubewertung im nächsten Frame
} else {
return;
}
}
Vector3f camDir = cam.getDirection().clone().setY(0).normalizeLocal();
Vector3f camLeft = cam.getLeft().clone().setY(0).normalizeLocal();

View File

@@ -6,7 +6,6 @@ import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.collision.shapes.CapsuleCollisionShape;
import com.jme3.bullet.collision.shapes.HeightfieldCollisionShape;
import com.jme3.bullet.control.CharacterControl;
import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.bullet.util.CollisionShapeFactory;
@@ -41,8 +40,11 @@ import de.blight.game.state.GrassState;
import de.blight.game.state.GrassVertexRenderState;
import de.blight.game.state.LocationState;
import de.blight.game.state.RiverState;
import de.blight.game.state.TerrainChunkState;
import de.blight.game.state.WaterBodyState;
import de.blight.game.state.WeatherState;
import de.blight.game.state.InteractionHudState;
import de.blight.game.state.WorldItemsState;
import de.blight.game.state.WorldObjectsState;
import java.io.IOException;
@@ -58,6 +60,7 @@ public class WorldScene extends BaseAppState {
private BulletAppState bulletAppState;
private MapData loadedMapData;
private FilterPostProcessor sharedFPP;
private TerrainChunkState terrainChunkState;
private final KeyBindings keyBindings;
private ThirdPersonCamera thirdPersonCam;
@@ -109,11 +112,12 @@ public class WorldScene extends BaseAppState {
buildLighting();
BlightGame.status("Lade Terrain...");
TerrainQuad terrain = buildTerrain();
buildChunkTerrain();
BlightGame.status("Lade Vegetation...");
app.getStateManager().attach(new GrassState(terrain));
app.getStateManager().attach(new GrassVertexRenderState());
app.getStateManager().attach(new GrassState(terrainChunkState));
GrassVertexRenderState gvrs = new GrassVertexRenderState(terrainChunkState);
app.getStateManager().attach(gvrs);
BlightGame.status("Lade Wasserflächen...");
app.getStateManager().attach(new WaterBodyState(sharedFPP));
@@ -147,6 +151,9 @@ public class WorldScene extends BaseAppState {
MainCharacter mc = findMainCharacter();
if (mc != null) {
app.getStateManager().attach(new LocationState(mc, character));
app.getStateManager().attach(
new WorldItemsState(keyBindings, physicsChar, mc, playerInput));
app.getStateManager().attach(new InteractionHudState());
}
// Maus einfangen keine Klick-Pflicht für Kamerasteuerung
@@ -322,117 +329,41 @@ public class WorldScene extends BaseAppState {
}
// -----------------------------------------------------------------------
// Terrain
// Terrain (Chunk-basiert)
// -----------------------------------------------------------------------
/**
* Baut das Terrain. Falls eine gespeicherte Karte vorhanden ist, wird diese
* geladen; andernfalls wird ein prozedurales Demo-Terrain erzeugt.
* Setzt außerdem {@link #spawnY}.
* Erstellt den chunk-basierten TerrainChunkState.
* Lädt MapData falls vorhanden, berechnet Spawn-Y aus Chunk-Höhen,
* erstellt ein gemeinsames Terrain-Material und hängt den State ein.
*/
private TerrainQuad buildTerrain() {
private void buildChunkTerrain() {
if (MapIO.exists()) {
try {
loadedMapData = MapIO.load();
return buildTerrainFromMap(loadedMapData);
} catch (IOException e) {
System.err.println("[WorldScene] Karte nicht ladbar: " + e.getMessage()
+ " — Fallback auf prozedurales Terrain.");
}
}
return buildProceduralTerrain();
}
/**
* Erstellt ein Terrain aus der gespeicherten {@link MapData}.
* Visuell: 4097×4097 Vertices (jeder 4. Editor-Vertex), Scale (1, 1, 1) = 1 m/Vertex.
* Physik: 513×513 Vertices (jeder 32. Editor-Vertex), Scale (8, 1, 8) unsichtbar,
* nur für Bullet-Kollision (33,5M Dreiecke bei 4097² wären zu viel für Bullet).
*/
private TerrainQuad buildTerrainFromMap(MapData map) {
final int SRC_VERTS = MapData.TERRAIN_VERTS; // 16385
// ── Visuelles Terrain (4097 Vertices, 1 m/Vertex) ────────────────────
final int GAME_VERTS = 4097;
final int STEP = (SRC_VERTS - 1) / (GAME_VERTS - 1); // 4
float[] heights = new float[GAME_VERTS * GAME_VERTS];
for (int gz = 0; gz < GAME_VERTS; gz++) {
int sz = gz * STEP;
for (int gx = 0; gx < GAME_VERTS; gx++) {
heights[gz * GAME_VERTS + gx] = map.terrainHeight[sz * SRC_VERTS + gx * STEP];
System.err.println("[WorldScene] Karte nicht ladbar: " + e.getMessage());
}
}
// Temp-Spawn aus Editor-Property überschreibt gespeicherten Karten-Spawn
// Spawn aus Map oder Editor-Property
String propX = System.getProperty("blight.temp.spawn.x");
String propZ = System.getProperty("blight.temp.spawn.z");
spawnX = propX != null ? Float.parseFloat(propX) : map.spawnX;
spawnZ = propZ != null ? Float.parseFloat(propZ) : map.spawnZ;
System.out.println("[WorldScene] SpawnXZ Quelle: " + (propX != null ? "Editor-Property" : "Karte")
+ " → X=" + spawnX + " Z=" + spawnZ);
TerrainQuad terrain = new TerrainQuad("terrain", 65, GAME_VERTS, heights);
terrain.setLocalScale(1f, 1f, 1f);
terrain.setShadowMode(RenderQueue.ShadowMode.Receive);
applyTerrainMaterial(terrain, map);
rootNode.attachChild(terrain);
// Terrain-Höhe am Spawnpunkt (scale=1 → lokale Koordinaten = Weltkoordinaten)
float terrainH = terrain.getHeight(new Vector2f(spawnX, spawnZ));
if (Float.isNaN(terrainH)) {
float maxH = -Float.MAX_VALUE;
for (float h : heights) { if (h > maxH) maxH = h; }
terrainH = maxH;
if (loadedMapData != null) {
spawnX = propX != null ? Float.parseFloat(propX) : loadedMapData.spawnX;
spawnZ = propZ != null ? Float.parseFloat(propZ) : loadedMapData.spawnZ;
}
System.out.println("[WorldScene] SpawnXZ: X=" + spawnX + " Z=" + spawnZ);
Material mat = buildTerrainMaterial(loadedMapData);
terrainChunkState = new TerrainChunkState(bulletAppState, mat, loadedMapData);
app.getStateManager().attach(terrainChunkState);
// Spawn-Höhe aus Chunk-Daten
float terrainH = terrainChunkState.getHeightAt(spawnX, spawnZ);
spawnY = terrainH + 10f;
// ── Physik-Terrain: HeightfieldCollisionShape mit identischem heights[]-Array ─
// Gleiche 4097×4097-Daten wie das visuelle Terrain → pixel-genaue Übereinstimmung.
// HeightfieldCollisionShape ist für Terrain optimiert (kein BVH, O(log n) Queries)
// und braucht keine separate TerrainQuad-Instanz.
RigidBodyControl terrainPhysics = new RigidBodyControl(
new HeightfieldCollisionShape(heights, terrain.getLocalScale()), 0f);
terrain.addControl(terrainPhysics);
bulletAppState.getPhysicsSpace().add(terrainPhysics);
System.out.println("[WorldScene] Karte geladen, SpawnXYZ=("
+ spawnX + ", " + spawnY + ", " + spawnZ + ")");
return terrain;
}
/** Prozedurales Demo-Terrain als Fallback (keine gespeicherte Karte). */
private TerrainQuad buildProceduralTerrain() {
int size = 257;
float[] heights = new float[size * size];
for (int z = 0; z < size; z++) {
for (int x = 0; x < size; x++) {
float nx = x / (float) size;
float nz = z / (float) size;
heights[z * size + x] =
FastMath.sin(nx * FastMath.TWO_PI * 2) * 2f
+ FastMath.sin(nz * FastMath.TWO_PI * 3) * 1.5f
+ FastMath.sin((nx + nz) * FastMath.TWO_PI * 1.5f) * 1f;
}
}
spawnY = 5f;
TerrainQuad terrain = new TerrainQuad("terrain", 65, size, heights);
terrain.setLocalTranslation(0, -5f, 0);
terrain.setLocalScale(0.5f, 0.5f, 0.5f);
terrain.setShadowMode(RenderQueue.ShadowMode.Receive);
applyTerrainMaterial(terrain, null);
rootNode.attachChild(terrain);
RigidBodyControl terrainPhysics = new RigidBodyControl(
CollisionShapeFactory.createMeshShape(terrain), 0f);
terrain.addControl(terrainPhysics);
bulletAppState.getPhysicsSpace().add(terrainPhysics);
return terrain;
System.out.println("[WorldScene] SpawnXYZ=(" + spawnX + ", " + spawnY + ", " + spawnZ + ")");
}
// -----------------------------------------------------------------------
@@ -588,7 +519,7 @@ public class WorldScene extends BaseAppState {
new ColorRGBA(0.80f, 0.72f, 0.50f, 1f),
};
private void applyTerrainMaterial(TerrainQuad terrain, MapData map) {
private Material buildTerrainMaterial(MapData map) {
if (map != null) {
try {
Material mat = new Material(assetManager, "Common/MatDefs/Terrain/TerrainLighting.j3md");
@@ -630,9 +561,7 @@ public class WorldScene extends BaseAppState {
splatTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
splatTex.setMagFilter(Texture.MagFilter.Bilinear);
mat.setTexture("AlphaMap", splatTex);
terrain.setMaterial(mat);
return;
return mat;
} catch (Exception e) {
System.err.println("[WorldScene] Splat-Material fehlgeschlagen: " + e.getMessage());
}
@@ -646,13 +575,13 @@ public class WorldScene extends BaseAppState {
mat.setTexture("DiffuseMap", grass);
mat.setFloat("DiffuseMap_0_scale", 32f);
mat.setBoolean("useTriPlanarMapping", false);
terrain.setMaterial(mat);
return mat;
} catch (Exception e) {
Material mat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
mat.setBoolean("UseMaterialColors", true);
mat.setColor("Diffuse", new ColorRGBA(0.28f, 0.58f, 0.18f, 1f));
mat.setColor("Ambient", new ColorRGBA(0.15f, 0.30f, 0.09f, 1f));
terrain.setMaterial(mat);
return mat;
}
}

View File

@@ -16,7 +16,6 @@ import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.AbstractControl;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.GrassTuft;
import de.blight.common.GrassTuftIO;
@@ -44,7 +43,7 @@ public class GrassState extends BaseAppState {
private static final float FAR_DIST_SQ = FAR_DIST * FAR_DIST;
private static final int INIT_PER_FRAME = 4;
private final TerrainQuad terrain;
private final TerrainChunkState terrainChunkState;
private Camera cam;
private Node grassNode;
@@ -54,8 +53,8 @@ public class GrassState extends BaseAppState {
private final Map<Integer, Material> slotMaterials = new LinkedHashMap<>();
private int nextChunk = 0;
public GrassState(TerrainQuad terrain) {
this.terrain = terrain;
public GrassState(TerrainChunkState terrainChunkState) {
this.terrainChunkState = terrainChunkState;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -176,7 +175,7 @@ public class GrassState extends BaseAppState {
for (int b = 0; b < BLADES_PER_TUFT; b++) {
float bx = t.x() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
float bz = t.z() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
float th = terrain.getHeight(new Vector2f(bx, bz));
float th = terrainChunkState.getHeightAt(bx, bz);
if (Float.isNaN(th)) continue;
float h = t.height() * (0.7f + rng.nextFloat() * 0.6f);
blades.add(new float[]{bx, th, bz, h});

View File

@@ -8,15 +8,11 @@ import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.AbstractControl;
import com.jme3.util.BufferUtils;
import de.blight.common.GrassVertexBlade;
import de.blight.common.GrassVertexIO;
@@ -27,8 +23,10 @@ import java.util.List;
/**
* Rendert Vertex-Gras-Büschel im Spiel: 3 geneigte, verjüngte Halme pro Büschel,
* beleuchtet über NormalBuffer + Custom-Shader, chunk-basiertes Lazy-Loading.
* Implementiert {@link TerrainChunkState.ChunkListener}: Gras wird ab LOD 1 ausgeblendet.
*/
public class GrassVertexRenderState extends BaseAppState {
public class GrassVertexRenderState extends BaseAppState
implements TerrainChunkState.ChunkListener {
// ── Chunks ────────────────────────────────────────────────────────────────
private static final int TERRAIN_HALF = 2048;
@@ -36,20 +34,23 @@ public class GrassVertexRenderState extends BaseAppState {
private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE;
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS;
private static final int INIT_PER_FRAME = 4;
private static final float FAR_DIST_SQ = 200f * 200f;
// ── Geometrie (identisch zu GrassVertexState) ─────────────────────────────
private static final int BLADES_PER_TUFT = 3;
private static final int SEGMENTS = 5;
private static final float WIDTH_FACTOR = 0.05f;
private static final float BEND_FACTOR = 0.15f;
private static final ColorRGBA ROOT_COLOR = new ColorRGBA(0.08f, 0.34f, 0.04f, 1f);
private static final ColorRGBA TIP_COLOR = new ColorRGBA(0.26f, 0.72f, 0.11f, 1f);
private static final ColorRGBA DRY_ROOT_COLOR = new ColorRGBA(0.45f, 0.35f, 0.08f, 1f);
private static final ColorRGBA DRY_TIP_COLOR = new ColorRGBA(0.82f, 0.74f, 0.16f, 1f);
private static final ColorRGBA ROOT_COLOR = new ColorRGBA(0.08f, 0.34f, 0.04f, 1f);
private static final ColorRGBA TIP_COLOR = new ColorRGBA(0.26f, 0.72f, 0.11f, 1f);
// 050 % Trockenheit: Grün → Goldgelb
private static final ColorRGBA DRY_ROOT_COLOR = new ColorRGBA(0.45f, 0.35f, 0.08f, 1f);
private static final ColorRGBA DRY_TIP_COLOR = new ColorRGBA(0.82f, 0.74f, 0.16f, 1f);
// 50100 % Trockenheit: Goldgelb → Dunkelbraun
private static final ColorRGBA VERY_DRY_ROOT_COLOR = new ColorRGBA(0.18f, 0.09f, 0.02f, 1f);
private static final ColorRGBA VERY_DRY_TIP_COLOR = new ColorRGBA(0.38f, 0.20f, 0.05f, 1f);
// ── Zustand ───────────────────────────────────────────────────────────────
private Camera cam;
private final TerrainChunkState terrainChunkState;
private AssetManager assetManager;
private Node grassNode;
private Material material;
@@ -59,12 +60,16 @@ public class GrassVertexRenderState extends BaseAppState {
private final List<GrassVertexBlade>[] chunkBlades = new List[CHUNK_COUNT];
private final Node[] chunkNodes = new Node[CHUNK_COUNT];
public GrassVertexRenderState(TerrainChunkState terrainChunkState) {
this.terrainChunkState = terrainChunkState;
}
@Override
protected void initialize(Application app) {
this.cam = app.getCamera();
this.assetManager = app.getAssetManager();
grassNode = new Node("grassVertexNode");
((SimpleApplication) app).getRootNode().attachChild(grassNode);
terrainChunkState.addChunkListener(this);
for (int i = 0; i < CHUNK_COUNT; i++) chunkBlades[i] = new ArrayList<>();
@@ -82,6 +87,7 @@ public class GrassVertexRenderState extends BaseAppState {
@Override
protected void cleanup(Application app) {
terrainChunkState.removeChunkListener(this);
((SimpleApplication) app).getRootNode().detachChild(grassNode);
}
@@ -158,18 +164,35 @@ public class GrassVertexRenderState extends BaseAppState {
Geometry geo = new Geometry("gv_" + ci, mesh);
geo.setMaterial(material);
int cx = ci % CHUNKS_PER_AXIS;
int cz = ci / CHUNKS_PER_AXIS;
float chunkCX = -TERRAIN_HALF + cx * CHUNK_SIZE + CHUNK_SIZE * 0.5f;
float chunkCZ = -TERRAIN_HALF + cz * CHUNK_SIZE + CHUNK_SIZE * 0.5f;
Node node = new Node("gvc_" + ci);
node.attachChild(geo);
node.addControl(new VisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
chunkNodes[ci] = node;
grassNode.attachChild(node);
}
// ── ChunkListener: Gras ab LOD 1 ausblenden ───────────────────────────────
@Override
public void onChunkVisible(int cx, int cz, int lod) {
setChunkVisible(cx, cz, lod == 0);
}
@Override
public void onChunkHidden(int cx, int cz) {
setChunkVisible(cx, cz, false);
}
@Override
public void onChunkLodChanged(int cx, int cz, int oldLod, int newLod) {
setChunkVisible(cx, cz, newLod == 0);
}
private void setChunkVisible(int cx, int cz, boolean visible) {
int ci = cz * CHUNKS_PER_AXIS + cx;
if (ci < 0 || ci >= CHUNK_COUNT || chunkNodes[ci] == null) return;
chunkNodes[ci].setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
// ── Mesh-Logik (gespiegelt zu GrassVertexState) ───────────────────────────
private static void buildTuft(float[] pos, float[] nrm, float[] col, float[] tex, int[] idx,
@@ -272,34 +295,26 @@ public class GrassVertexRenderState extends BaseAppState {
float dr = DRY_ROOT_COLOR.r + (DRY_TIP_COLOR.r - DRY_ROOT_COLOR.r) * wf;
float dg = DRY_ROOT_COLOR.g + (DRY_TIP_COLOR.g - DRY_ROOT_COLOR.g) * wf;
float db = DRY_ROOT_COLOR.b + (DRY_TIP_COLOR.b - DRY_ROOT_COLOR.b) * wf;
col[ci] = gr + (dr - gr) * dryness;
col[ci+1] = gg + (dg - gg) * dryness;
col[ci+2] = gb + (db - gb) * dryness;
col[ci+3] = 1f;
float vr = VERY_DRY_ROOT_COLOR.r + (VERY_DRY_TIP_COLOR.r - VERY_DRY_ROOT_COLOR.r) * wf;
float vg = VERY_DRY_ROOT_COLOR.g + (VERY_DRY_TIP_COLOR.g - VERY_DRY_ROOT_COLOR.g) * wf;
float vb = VERY_DRY_ROOT_COLOR.b + (VERY_DRY_TIP_COLOR.b - VERY_DRY_ROOT_COLOR.b) * wf;
// Zwei-Segment-Gradient: 0→0.5 = grün→goldgelb, 0.5→1.0 = goldgelb→dunkelbraun
float fr, fg, fb;
if (dryness <= 0.5f) {
float t = dryness * 2f;
fr = gr + (dr - gr) * t;
fg = gg + (dg - gg) * t;
fb = gb + (db - gb) * t;
} else {
float t = (dryness - 0.5f) * 2f;
fr = dr + (vr - dr) * t;
fg = dg + (vg - dg) * t;
fb = db + (vb - db) * t;
}
col[ci] = fr; col[ci+1] = fg; col[ci+2] = fb; col[ci+3] = 1f;
int ti = vi * 2;
tex[ti] = wf; tex[ti+1] = 0f;
}
// ── LOD-Culling ───────────────────────────────────────────────────────────
private static final class VisibilityControl extends AbstractControl {
private final Camera cam;
private final Vector3f center;
VisibilityControl(Camera cam, Vector3f center) {
this.cam = cam;
this.center = center;
}
@Override
protected void controlUpdate(float tpf) {
float dx = cam.getLocation().x - center.x;
float dz = cam.getLocation().z - center.z;
spatial.setCullHint(dx*dx + dz*dz <= FAR_DIST_SQ
? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
@Override protected void controlRender(RenderManager rm, ViewPort vp) {}
}
}

View File

@@ -0,0 +1,135 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.font.BitmapFont;
import com.jme3.font.BitmapText;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import de.blight.common.model.Item;
import de.blight.common.model.TextRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Zeigt oberhalb von nahegelegenen Item-Pickups den Namen an,
* wenn der Spieler darauf zielt.
*/
public class InteractionHudState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(InteractionHudState.class);
private static final float SHOW_RANGE = 3.5f;
private static final float DOT_THRESH = 0.65f;
private static final float Y_OFFSET = 0.6f;
private Camera cam;
private Node guiNode;
private WorldItemsState worldItems;
private BitmapText labelText;
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
SimpleApplication sapp = (SimpleApplication) app;
this.cam = app.getCamera();
this.guiNode = sapp.getGuiNode();
BitmapFont font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
labelText = new BitmapText(font, false);
labelText.setSize(font.getCharSet().getRenderedSize() * 1.2f);
labelText.setColor(new ColorRGBA(1f, 0.95f, 0.6f, 1f));
labelText.setCullHint(Spatial.CullHint.Always);
guiNode.attachChild(labelText);
}
@Override
protected void onEnable() {
worldItems = getStateManager().getState(WorldItemsState.class);
if (worldItems == null)
log.warn("[InteractionHud] WorldItemsState nicht gefunden.");
}
@Override
protected void onDisable() {
labelText.setCullHint(Spatial.CullHint.Always);
}
@Override
protected void cleanup(Application app) {
guiNode.detachChild(labelText);
}
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (worldItems == null) {
labelText.setCullHint(Spatial.CullHint.Always);
return;
}
Node itemsRoot = worldItems.getItemsRoot();
if (itemsRoot == null || itemsRoot.getQuantity() == 0) {
labelText.setCullHint(Spatial.CullHint.Always);
return;
}
Vector3f playerPos = worldItems.getPhysicsChar() != null
? worldItems.getPhysicsChar().getPhysicsLocation()
: cam.getLocation();
Vector3f camDir = cam.getDirection().normalizeLocal();
Spatial bestTarget = null;
float bestDot = -1f;
for (Spatial s : itemsRoot.getChildren()) {
Vector3f itemPos = s.getWorldTranslation();
float dx = itemPos.x - playerPos.x;
float dz = itemPos.z - playerPos.z;
float dist = (float) Math.sqrt(dx * dx + dz * dz);
if (dist > SHOW_RANGE) continue;
Vector3f toItem = itemPos.subtract(cam.getLocation()).normalizeLocal();
float dot = camDir.dot(toItem);
if (dot > DOT_THRESH && dot > bestDot) {
bestDot = dot;
bestTarget = s;
}
}
if (bestTarget == null) {
labelText.setCullHint(Spatial.CullHint.Always);
return;
}
String label = resolveLabel(bestTarget);
labelText.setText(label);
Vector3f worldPos = bestTarget.getWorldTranslation().add(0f, Y_OFFSET, 0f);
Vector3f screenV3 = cam.getScreenCoordinates(worldPos);
Vector2f screen = new Vector2f(screenV3.x, screenV3.y);
float textW = labelText.getLineWidth();
labelText.setLocalTranslation(screen.x - textW * 0.5f, screen.y, 1f);
labelText.setCullHint(Spatial.CullHint.Inherit);
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private String resolveLabel(Spatial s) {
String itemId = s.getUserData("itemId");
if (itemId == null) return "?";
WorldItemsState state = worldItems;
// Resolve via TextRegistry if a full Item definition is available
// For now fall back to itemId
return itemId;
}
}

View File

@@ -0,0 +1,121 @@
package de.blight.game.state;
import com.jme3.asset.AssetManager;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.renderer.Camera;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.control.AbstractControl;
/**
* Wechselt zwischen Main-Model, LOD1, LOD2 und Ausblenden basierend auf Kameradistanz.
*
* Struktur des kontrollierten Node:
* [0] = Haupt-Spatial (LOD0)
* [1] = LOD1-Spatial (wird lazy geladen, wenn lod1Path gesetzt)
* [2] = LOD2-Spatial (wird lazy geladen, wenn lod2Path gesetzt)
*/
public class ModelLodControl extends AbstractControl {
private final AssetManager assets;
private final String lod1Path;
private final String lod2Path;
private final float lod1DistSq;
private final float lod2DistSq;
private final float cullDistSq;
private boolean lod1Loaded = false;
private boolean lod2Loaded = false;
private int currentSlot = 0; // 0=main, 1=lod1, 2=lod2, -1=culled
public ModelLodControl(AssetManager assets,
String lod1Path, String lod2Path,
float lod1Distance, float lod2Distance, float cullDistance) {
this.assets = assets;
this.lod1Path = (lod1Path != null && !lod1Path.isBlank()) ? lod1Path : null;
this.lod2Path = (lod2Path != null && !lod2Path.isBlank()) ? lod2Path : null;
this.lod1DistSq = lod1Distance * lod1Distance;
this.lod2DistSq = lod2Distance * lod2Distance;
this.cullDistSq = cullDistance * cullDistance;
}
@Override
protected void controlUpdate(float tpf) {
if (!(spatial instanceof Node node)) return;
Camera cam = null;
// walk up to get the app's camera via the scene
// We use the ViewPort supplied during render; cache camera via controlRender instead.
// Nothing to do here without camera ref — logic moved to controlRender.
}
@Override
protected void controlRender(RenderManager rm, ViewPort vp) {
if (!(spatial instanceof Node node)) return;
Camera cam = vp.getCamera();
float dx = cam.getLocation().x - spatial.getWorldTranslation().x;
float dy = cam.getLocation().y - spatial.getWorldTranslation().y;
float dz = cam.getLocation().z - spatial.getWorldTranslation().z;
float distSq = dx*dx + dy*dy + dz*dz;
int targetSlot;
if (distSq >= cullDistSq) {
targetSlot = -1;
} else if (lod2Path != null && distSq >= lod2DistSq) {
targetSlot = 2;
} else if (lod1Path != null && distSq >= lod1DistSq) {
targetSlot = 1;
} else {
targetSlot = 0;
}
if (targetSlot == currentSlot) return;
currentSlot = targetSlot;
// Ensure LOD spatials are loaded
if (targetSlot == 1 && !lod1Loaded) {
lod1Loaded = true;
try {
Spatial lod1 = assets.loadModel(lod1Path);
lod1.setName("lod1");
node.attachChildAt(lod1, 1);
} catch (Exception e) {
// LOD1 load failed — fall back to main
currentSlot = 0;
targetSlot = 0;
}
}
if (targetSlot == 2 && !lod2Loaded) {
lod2Loaded = true;
try {
Spatial lod2 = assets.loadModel(lod2Path);
lod2.setName("lod2");
// ensure index 2 exists
if (node.getChildren().size() < 2 && lod1Path != null && !lod1Loaded) {
// lod1 slot not yet loaded — add placeholder to maintain index
Node placeholder = new Node("lod1_placeholder");
node.attachChild(placeholder);
}
node.attachChild(lod2);
} catch (Exception e) {
currentSlot = lod1Path != null ? 1 : 0;
targetSlot = currentSlot;
}
}
// Apply visibility: cull hint on node itself for -1, else show correct child
if (targetSlot == -1) {
spatial.setCullHint(Spatial.CullHint.Always);
return;
}
spatial.setCullHint(Spatial.CullHint.Dynamic);
for (int i = 0; i < node.getChildren().size(); i++) {
Spatial child = node.getChildren().get(i);
child.setCullHint(i == targetSlot
? Spatial.CullHint.Dynamic
: Spatial.CullHint.Always);
}
}
}

View File

@@ -0,0 +1,381 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.collision.shapes.HeightfieldCollisionShape;
import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.material.Material;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.VertexBuffer;
import com.jme3.util.BufferUtils;
import de.blight.common.ChunkTerrainIO;
import de.blight.common.MapData;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Verwaltet das chunk-basierte LOD-Terrain im Spiel.
*
* 1024 Chunks à 128×128 m, jeweils 3 LOD-Stufen (1/4/16 m pro Vertex).
* LOD richtet sich nach Chebyshev-Distanz zum Spieler-Chunk.
* Physik-Collider (HeightfieldCollisionShape, native 129²-Auflösung) werden
* nur für Chunks innerhalb PHYSICS_RANGE gehalten.
*
* Registrierte {@link ChunkListener} werden über LOD-Wechsel informiert
* (z. B. GrassVertexRenderState versteckt Gras ab LOD 1).
*/
public class TerrainChunkState extends BaseAppState {
// ── Listener-Interface ────────────────────────────────────────────────────
public interface ChunkListener {
void onChunkVisible(int cx, int cz, int lod);
void onChunkHidden(int cx, int cz);
void onChunkLodChanged(int cx, int cz, int oldLod, int newLod);
}
// ── Konstanten ────────────────────────────────────────────────────────────
private static final int N = ChunkTerrainIO.CHUNKS_PER_AXIS; // 32
private static final int TOTAL = ChunkTerrainIO.CHUNK_COUNT; // 1024
// ── Eingabe ───────────────────────────────────────────────────────────────
private final BulletAppState bulletAppState;
private final Material terrainMaterial;
private final MapData mapData;
// ── Laufzeitstatus ────────────────────────────────────────────────────────
private SimpleApplication app;
private Camera cam;
private Node terrainRoot;
private final float[][] chunkHeights = new float[TOTAL][];
private final int[] chunkLod = new int[TOTAL];
private final Node[] chunkNodes = new Node[TOTAL];
private final RigidBodyControl[] physics = new RigidBodyControl[TOTAL];
private final List<ChunkListener> listeners = new ArrayList<>();
private int lastPlayerCx = Integer.MIN_VALUE;
private int lastPlayerCz = Integer.MIN_VALUE;
// ── Konstruktor ───────────────────────────────────────────────────────────
public TerrainChunkState(BulletAppState bulletAppState, Material terrainMaterial, MapData mapData) {
this.bulletAppState = bulletAppState;
this.terrainMaterial = terrainMaterial;
this.mapData = mapData;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.cam = app.getCamera();
Arrays.fill(chunkLod, -1);
terrainRoot = new Node("terrainChunks");
this.app.getRootNode().attachChild(terrainRoot);
if (!ChunkTerrainIO.allChunksExist()) {
try {
if (mapData != null)
ChunkTerrainIO.exportFromMapData(mapData);
else
ChunkTerrainIO.exportBlankChunks();
System.out.println("[TerrainChunkState] Chunk-Dateien erzeugt.");
} catch (IOException e) {
System.err.println("[TerrainChunkState] Chunk-Export fehlgeschlagen: " + e);
}
}
for (int cz = 0; cz < N; cz++) {
for (int cx = 0; cx < N; cx++) {
int ci = ChunkTerrainIO.chunkIndex(cx, cz);
try {
chunkHeights[ci] = ChunkTerrainIO.loadChunk(cx, cz);
} catch (IOException e) {
chunkHeights[ci] = flatChunk();
System.err.println("[TerrainChunkState] Chunk " + cx + "," + cz + " nicht ladbar: " + e.getMessage());
}
}
}
System.out.println("[TerrainChunkState] " + TOTAL + " Chunks geladen.");
}
@Override
protected void cleanup(Application app) {
for (int ci = 0; ci < TOTAL; ci++) removePhysics(ci);
((SimpleApplication) app).getRootNode().detachChild(terrainRoot);
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
// ── Update: LOD + Physik ──────────────────────────────────────────────────
@Override
public void update(float tpf) {
Vector3f camPos = cam.getLocation();
int pcx = worldToChunk(camPos.x);
int pcz = worldToChunk(camPos.z);
if (pcx == lastPlayerCx && pcz == lastPlayerCz) return;
lastPlayerCx = pcx;
lastPlayerCz = pcz;
// Ziel-LOD für alle Chunks berechnen
int[] targetLod = new int[TOTAL];
boolean[] dirty = new boolean[TOTAL];
for (int cz = 0; cz < N; cz++) {
for (int cx = 0; cx < N; cx++) {
int ci = ChunkTerrainIO.chunkIndex(cx, cz);
int dist = ChunkTerrainIO.chebyshev(cx, cz, pcx, pcz);
targetLod[ci] = ChunkTerrainIO.lodForDistance(dist);
dirty[ci] = targetLod[ci] != chunkLod[ci];
}
}
// Schmutzige Chunks + ihre Nachbarn neu bauen (Seam-Korrektheit)
boolean[] rebuildSet = dirty.clone();
for (int cz = 0; cz < N; cz++) {
for (int cx = 0; cx < N; cx++) {
if (!dirty[ChunkTerrainIO.chunkIndex(cx, cz)]) continue;
if (cx > 0) rebuildSet[ChunkTerrainIO.chunkIndex(cx-1, cz)] = true;
if (cx < N-1) rebuildSet[ChunkTerrainIO.chunkIndex(cx+1, cz)] = true;
if (cz > 0) rebuildSet[ChunkTerrainIO.chunkIndex(cx, cz-1)] = true;
if (cz < N-1) rebuildSet[ChunkTerrainIO.chunkIndex(cx, cz+1)] = true;
}
}
for (int cz = 0; cz < N; cz++) {
for (int cx = 0; cx < N; cx++) {
int ci = ChunkTerrainIO.chunkIndex(cx, cz);
if (!rebuildSet[ci]) continue;
int oldLod = chunkLod[ci];
int newLod = targetLod[ci];
rebuildChunkMesh(cx, cz, newLod, targetLod);
// Physik: nur für nahe Chunks halten
int dist = ChunkTerrainIO.chebyshev(cx, cz, pcx, pcz);
boolean wantsPhysics = dist <= ChunkTerrainIO.PHYSICS_RANGE;
if (wantsPhysics && physics[ci] == null) addPhysics(ci);
if (!wantsPhysics && physics[ci] != null) removePhysics(ci);
// Listener benachrichtigen
if (oldLod < 0) {
notifyVisible(cx, cz, newLod);
} else if (newLod != oldLod) {
notifyLodChanged(cx, cz, oldLod, newLod);
}
}
}
}
// ── Öffentliche API ───────────────────────────────────────────────────────
/** Bilinear interpolierte Terrain-Höhe an einer Welt-XZ-Position. */
public float getHeightAt(float worldX, float worldZ) {
int cx = Math.max(0, Math.min(N-1, worldToChunk(worldX)));
int cz = Math.max(0, Math.min(N-1, worldToChunk(worldZ)));
float[] h = chunkHeights[ChunkTerrainIO.chunkIndex(cx, cz)];
if (h == null) return 1f;
float originX = -2048f + cx * ChunkTerrainIO.CHUNK_SIZE;
float originZ = -2048f + cz * ChunkTerrainIO.CHUNK_SIZE;
float fx = Math.max(0, Math.min(ChunkTerrainIO.CHUNK_SIZE, worldX - originX));
float fz = Math.max(0, Math.min(ChunkTerrainIO.CHUNK_SIZE, worldZ - originZ));
int V = ChunkTerrainIO.CHUNK_VERTS; // 129
int col0 = Math.min((int) fx, V - 2);
int row0 = Math.min((int) fz, V - 2);
float tx = fx - col0;
float tz = fz - row0;
float h00 = h[row0 * V + col0];
float h10 = h[row0 * V + col0 + 1];
float h01 = h[(row0 + 1) * V + col0];
float h11 = h[(row0 + 1) * V + col0 + 1];
return h00 * (1-tx)*(1-tz) + h10 * tx*(1-tz) + h01 * (1-tx)*tz + h11 * tx*tz;
}
public void addChunkListener(ChunkListener l) { listeners.add(l); }
public void removeChunkListener(ChunkListener l) { listeners.remove(l); }
// ── Chunk-Mesh-Aufbau ─────────────────────────────────────────────────────
private void rebuildChunkMesh(int cx, int cz, int lod, int[] targetLod) {
int ci = ChunkTerrainIO.chunkIndex(cx, cz);
// Knoten anlegen oder altes Mesh entfernen
if (chunkNodes[ci] == null) {
float nx = -2048f + cx * ChunkTerrainIO.CHUNK_SIZE + 64f;
float nz = -2048f + cz * ChunkTerrainIO.CHUNK_SIZE + 64f;
Node node = new Node("tn_" + cx + "_" + cz);
node.setLocalTranslation(nx, 0f, nz);
chunkNodes[ci] = node;
terrainRoot.attachChild(node);
} else {
chunkNodes[ci].detachAllChildren();
}
int verts = ChunkTerrainIO.LOD_VERTS[lod];
float spacing = ChunkTerrainIO.LOD_SPACING[lod];
float[] lodH = ChunkTerrainIO.downsample(chunkHeights[ci], ChunkTerrainIO.CHUNK_VERTS, verts);
applySeams(cx, cz, lod, lodH, verts, targetLod);
Mesh mesh = buildMesh(cx, cz, lodH, verts, spacing);
Geometry geom = new Geometry("tc_" + cx + "_" + cz, mesh);
geom.setMaterial(terrainMaterial);
geom.setShadowMode(RenderQueue.ShadowMode.Receive);
chunkNodes[ci].attachChild(geom);
chunkLod[ci] = lod;
}
private void applySeams(int cx, int cz, int lod, float[] lodH, int verts, int[] targetLod) {
int[] dxArr = { 0, 0, 1, -1 };
int[] dzArr = { 1, -1, 0, 0 };
int[] thisEdges = { ChunkTerrainIO.EDGE_NORTH, ChunkTerrainIO.EDGE_SOUTH,
ChunkTerrainIO.EDGE_EAST, ChunkTerrainIO.EDGE_WEST };
int[] neighborEdge = { ChunkTerrainIO.EDGE_SOUTH, ChunkTerrainIO.EDGE_NORTH,
ChunkTerrainIO.EDGE_WEST, ChunkTerrainIO.EDGE_EAST };
for (int e = 0; e < 4; e++) {
int ncx = cx + dxArr[e];
int ncz = cz + dzArr[e];
if (ncx < 0 || ncx >= N || ncz < 0 || ncz >= N) continue;
int nci = ChunkTerrainIO.chunkIndex(ncx, ncz);
int neighborLod = targetLod[nci] < 0 ? 0 : targetLod[nci];
if (neighborLod <= lod || chunkHeights[nci] == null) continue;
int nVerts = ChunkTerrainIO.LOD_VERTS[neighborLod];
float[] nLodH = ChunkTerrainIO.downsample(chunkHeights[nci], ChunkTerrainIO.CHUNK_VERTS, nVerts);
float[] nEdgeVals = ChunkTerrainIO.extractEdge(nLodH, nVerts, neighborEdge[e]);
ChunkTerrainIO.stitchEdge(lodH, verts, nEdgeVals, nVerts, thisEdges[e]);
}
}
private static Mesh buildMesh(int cx, int cz, float[] lodH, int verts, float spacing) {
float chunkCX = -2048f + cx * ChunkTerrainIO.CHUNK_SIZE + 64f;
float chunkCZ = -2048f + cz * ChunkTerrainIO.CHUNK_SIZE + 64f;
float half = (verts - 1) * spacing * 0.5f; // immer 64 m
int vertCount = verts * verts;
int indexCount = (verts - 1) * (verts - 1) * 6;
float[] positions = new float[vertCount * 3];
float[] normals = new float[vertCount * 3];
float[] texCoords = new float[vertCount * 2];
int[] indices = new int[indexCount];
for (int row = 0; row < verts; row++) {
for (int col = 0; col < verts; col++) {
int vi = row * verts + col;
float lx = col * spacing - half;
float lz = row * spacing - half;
float h = lodH[vi];
int pi = vi * 3;
positions[pi] = lx; positions[pi+1] = h; positions[pi+2] = lz;
// Normale per zentrale Differenzen
float hL = lodH[row * verts + Math.max(0, col-1)];
float hR = lodH[row * verts + Math.min(verts-1, col+1)];
float hD = lodH[Math.max(0, row-1) * verts + col];
float hU = lodH[Math.min(verts-1, row+1) * verts + col];
float nx = -(hR - hL);
float ny = 2f * spacing;
float nz = -(hU - hD);
float nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz);
if (nLen > 1e-6f) { nx /= nLen; ny /= nLen; nz /= nLen; }
normals[pi] = nx; normals[pi+1] = ny; normals[pi+2] = nz;
// Welt-Raum UV (01 über 4096 m) für globale Splat-Map
float worldX = chunkCX + lx;
float worldZ = chunkCZ + lz;
int ti = vi * 2;
texCoords[ti] = (worldX + 2048f) / 4096f;
texCoords[ti+1] = (worldZ + 2048f) / 4096f;
}
}
int ii = 0;
for (int row = 0; row < verts - 1; row++) {
for (int col = 0; col < verts - 1; col++) {
int v00 = row * verts + col;
int v10 = v00 + 1;
int v01 = v00 + verts;
int v11 = v01 + 1;
indices[ii++] = v00; indices[ii++] = v01; indices[ii++] = v10;
indices[ii++] = v10; indices[ii++] = v01; indices[ii++] = v11;
}
}
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(positions));
mesh.setBuffer(VertexBuffer.Type.Normal, 3, BufferUtils.createFloatBuffer(normals));
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(texCoords));
mesh.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(indices));
mesh.updateBound();
mesh.updateCounts();
return mesh;
}
// ── Physik ────────────────────────────────────────────────────────────────
private void addPhysics(int ci) {
if (chunkNodes[ci] == null || chunkHeights[ci] == null) return;
HeightfieldCollisionShape shape = new HeightfieldCollisionShape(
chunkHeights[ci], new Vector3f(1f, 1f, 1f));
RigidBodyControl rbc = new RigidBodyControl(shape, 0f);
chunkNodes[ci].addControl(rbc);
bulletAppState.getPhysicsSpace().add(rbc);
physics[ci] = rbc;
}
private void removePhysics(int ci) {
if (physics[ci] == null) return;
bulletAppState.getPhysicsSpace().remove(physics[ci]);
if (chunkNodes[ci] != null) chunkNodes[ci].removeControl(physics[ci]);
physics[ci] = null;
}
// ── Listener ─────────────────────────────────────────────────────────────
private void notifyVisible(int cx, int cz, int lod) {
for (ChunkListener l : listeners) l.onChunkVisible(cx, cz, lod);
}
private void notifyLodChanged(int cx, int cz, int oldLod, int newLod) {
for (ChunkListener l : listeners) l.onChunkLodChanged(cx, cz, oldLod, newLod);
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private static int worldToChunk(float world) {
return Math.max(0, Math.min(N - 1, (int) ((world + 2048f) / ChunkTerrainIO.CHUNK_SIZE)));
}
private static float[] flatChunk() {
float[] h = new float[ChunkTerrainIO.CHUNK_VERTS * ChunkTerrainIO.CHUNK_VERTS];
Arrays.fill(h, 1f);
return h;
}
}

View File

@@ -0,0 +1,230 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.asset.plugins.FileLocator;
import com.jme3.bullet.control.CharacterControl;
import com.jme3.input.InputManager;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.shape.Box;
import de.blight.common.PlacedItem;
import de.blight.common.PlacedItemIO;
import de.blight.common.model.Inventar;
import de.blight.common.model.Item;
import de.blight.common.model.ItemIO;
import de.blight.common.model.MainCharacter;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.config.KeyBindings;
import de.blight.game.control.PlayerInputControl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Lädt alle auf der Karte platzierten Items, stellt sie als 3D-Objekte dar und
* verarbeitet das Aufheben (E-Taste) mit PICK_UP-Animation.
*/
public class WorldItemsState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WorldItemsState.class);
private static final float PICKUP_RANGE = 2.5f;
private static final float PICKUP_ANIM_DURATION = 0.8f;
private static final String INTERACT_ACTION = "Interact";
private final KeyBindings keyBindings;
private final CharacterControl physicsChar;
private final MainCharacter mainCharacter;
private final PlayerInputControl playerInput;
private SimpleApplication app;
private AssetManager assets;
private InputManager inputManager;
private Node rootNode;
private Node itemsRoot;
private final List<PlacedItem> items = new ArrayList<>();
private final List<Spatial> visuals = new ArrayList<>();
private final Map<String, Item> itemDefs = new HashMap<>();
private final Quaternion rotQuat = new Quaternion();
private float rotAccum = 0f;
public WorldItemsState(KeyBindings keyBindings, CharacterControl physicsChar,
MainCharacter mainCharacter, PlayerInputControl playerInput) {
this.keyBindings = keyBindings;
this.physicsChar = physicsChar;
this.mainCharacter = mainCharacter;
this.playerInput = playerInput;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
this.inputManager = app.getInputManager();
this.rootNode = this.app.getRootNode();
this.itemsRoot = new Node("worldItemsRoot");
try {
assets.registerLocator(
AnimationLibrary.findAssetRoot().toAbsolutePath().toString(),
FileLocator.class);
} catch (Exception ignored) {}
Path itemDir = AnimationLibrary.findAssetRoot().resolve("items");
for (Item it : ItemIO.loadAll(itemDir)) {
if (it.getItemId() != null) itemDefs.put(it.getItemId(), it);
}
log.info("[WorldItems] {} Item-Definitionen geladen.", itemDefs.size());
}
@Override
protected void onEnable() {
items.clear();
visuals.clear();
try {
items.addAll(PlacedItemIO.load());
} catch (Exception e) {
log.warn("[WorldItems] Laden fehlgeschlagen: {}", e.getMessage());
}
for (PlacedItem pi : items) {
Spatial s = buildVisual(pi);
s.setLocalTranslation(pi.x(), pi.y() + 0.25f, pi.z());
s.setUserData("itemId", pi.itemId());
itemsRoot.attachChild(s);
visuals.add(s);
}
rootNode.attachChild(itemsRoot);
log.info("[WorldItems] {} Item-Pickups geladen.", items.size());
inputManager.addMapping(INTERACT_ACTION, new KeyTrigger(keyBindings.interact));
inputManager.addListener(interactListener, INTERACT_ACTION);
}
@Override
protected void onDisable() {
itemsRoot.detachAllChildren();
itemsRoot.removeFromParent();
visuals.clear();
items.clear();
inputManager.removeListener(interactListener);
try { inputManager.deleteMapping(INTERACT_ACTION); } catch (Exception ignored) {}
}
@Override
protected void cleanup(Application app) {}
@Override
public void update(float tpf) {
if (visuals.isEmpty()) return;
rotAccum += tpf * 60f;
rotQuat.fromAngles(0f, rotAccum * FastMath.DEG_TO_RAD, 0f);
for (Spatial s : visuals) s.setLocalRotation(rotQuat);
}
// ── Interaktion ───────────────────────────────────────────────────────────
private final ActionListener interactListener = (name, isPressed, tpf) -> {
if (isPressed) tryPickup();
};
private void tryPickup() {
if (physicsChar == null || items.isEmpty()) return;
Vector3f playerPos = physicsChar.getPhysicsLocation();
int nearest = -1;
float bestDist = Float.MAX_VALUE;
for (int i = 0; i < items.size(); i++) {
PlacedItem pi = items.get(i);
float dx = pi.x() - playerPos.x;
float dz = pi.z() - playerPos.z;
float dist = (float) Math.sqrt(dx * dx + dz * dz);
if (dist < PICKUP_RANGE && dist < bestDist) {
bestDist = dist;
nearest = i;
}
}
if (nearest < 0) return;
PlacedItem picked = items.get(nearest);
Spatial pickedSpatial = visuals.get(nearest);
pickedSpatial.removeFromParent();
items.remove(nearest);
visuals.remove(nearest);
// Kartendatei sofort aktualisieren
try {
PlacedItemIO.save(items);
} catch (IOException e) {
log.warn("[WorldItems] Speichern fehlgeschlagen: {}", e.getMessage());
}
// Ins Inventar legen
Item def = itemDefs.get(picked.itemId());
if (mainCharacter != null) {
if (mainCharacter.getInventar() == null) {
mainCharacter.setInventar(new Inventar());
}
if (def != null) {
mainCharacter.getInventar().collect(def);
log.info("[WorldItems] '{}' aufgehoben.", picked.itemId());
} else {
log.warn("[WorldItems] Keine Item-Definition für '{}'.", picked.itemId());
}
}
// PICK_UP-Animation spielen und Bewegung sperren
if (playerInput != null) {
playerInput.requestPickup(PICKUP_ANIM_DURATION);
}
}
// ── Zugriff ───────────────────────────────────────────────────────────────
public Node getItemsRoot() { return itemsRoot; }
public CharacterControl getPhysicsChar() { return physicsChar; }
// ── Visuelles ─────────────────────────────────────────────────────────────
private Spatial buildVisual(PlacedItem pi) {
Item def = itemDefs.get(pi.itemId());
if (def != null && def.getModelRef() != null
&& def.getModelRef().getPath() != null
&& !def.getModelRef().getPath().isBlank()) {
try {
Spatial model = assets.loadModel(def.getModelRef().getPath());
model.setName("item_" + pi.itemId());
return model;
} catch (Exception e) {
log.warn("[WorldItems] Modell für '{}' nicht ladbar: {}", pi.itemId(), e.getMessage());
}
}
// Fallback: goldener Würfel
Geometry g = new Geometry("item_" + pi.itemId(), new Box(0.15f, 0.15f, 0.15f));
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.82f, 0.1f, 1f));
g.setMaterial(mat);
return g;
}
}

View File

@@ -115,6 +115,19 @@ public class WorldObjectsState extends BaseAppState {
spatial = assets.loadModel(path);
}
spatial.setName("obj_" + path);
// LOD / Distance-Culling
if (m.cullDistance() > 0f) {
Node lodRoot = new Node("lodRoot_" + path);
lodRoot.attachChild(spatial);
ModelLodControl ctrl = new ModelLodControl(
assets,
m.lod1Path(), m.lod2Path(),
m.lod1Distance(), m.lod2Distance(), m.cullDistance());
lodRoot.addControl(ctrl);
return lodRoot;
}
return spatial;
}