Commit vor großem Terrain refactoring

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

View File

@@ -14,6 +14,7 @@ import de.blight.game.scene.WorldScene;
import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
@@ -35,6 +36,12 @@ public class BlightGame extends SimpleApplication {
private JWindow splashWindow;
// ── Splash-Status (von JME-Thread geschrieben, EDT liest) ─────────────────
static volatile String loadingStatus = "Initialisiere...";
static volatile boolean gameReady = false;
private static JLabel splashLabel;
private static Timer splashTimer;
public static void main(String[] args) {
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
@@ -42,6 +49,7 @@ public class BlightGame extends SimpleApplication {
BlightGame app = new BlightGame();
app.splashWindow = showSplash();
status("Lade Grafikeinstellungen...");
GraphicsSettings gs = GraphicsStore.load();
AppSettings settings = new AppSettings(true);
settings.setTitle("Blight");
@@ -53,21 +61,61 @@ public class BlightGame extends SimpleApplication {
settings.setVSync(gs.vsync);
settings.setSamples(gs.samples);
status("Initialisiere Renderer...");
app.setSettings(settings);
app.setShowSettings(false);
app.start();
}
/** Setzt den Ladetext, der im Splash-Screen angezeigt wird. Thread-sicher. */
public static void status(String msg) {
loadingStatus = msg;
}
private static JWindow showSplash() {
try {
BufferedImage img = ImageIO.read(BlightGame.class.getResourceAsStream("/logo.png"));
BufferedImage icon = ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png"));
// Logo skaliert auf 700 px Breite (Seitenverhältnis beibehalten)
BufferedImage img = ImageIO.read(BlightGame.class.getResourceAsStream("/logo.png"));
int tw = 700;
int th = (int) (img.getHeight() * (tw / (double) img.getWidth()));
Image scaled = img.getScaledInstance(tw, th, Image.SCALE_SMOOTH);
// Dunkles Panel
JPanel panel = new JPanel(new BorderLayout());
panel.setBackground(new Color(0x1c1c1c));
panel.setOpaque(true);
panel.add(new JLabel(new ImageIcon(scaled)), BorderLayout.CENTER);
splashLabel = new JLabel(loadingStatus);
splashLabel.setForeground(new Color(0xbb, 0xbb, 0xbb));
splashLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
splashLabel.setBorder(BorderFactory.createEmptyBorder(8, 16, 12, 16));
splashLabel.setOpaque(false);
panel.add(splashLabel, BorderLayout.SOUTH);
JWindow win = new JWindow();
if (icon != null) win.setIconImages(java.util.List.of(icon));
win.getContentPane().add(new JLabel(new ImageIcon(img)));
try {
BufferedImage icon = ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png"));
if (icon != null) win.setIconImages(java.util.List.of(icon));
} catch (Exception ignored) {}
win.setContentPane(panel);
win.pack();
win.setLocationRelativeTo(null);
win.setVisible(true);
// Pollt loadingStatus alle 100 ms analog zum Editor-Splash
splashTimer = new Timer(100, null);
splashTimer.addActionListener(e -> {
splashLabel.setText(loadingStatus);
if (gameReady) {
splashTimer.stop();
Timer closeDelay = new Timer(400, ev -> win.dispose());
closeDelay.setRepeats(false);
closeDelay.start();
}
});
splashTimer.start();
return win;
} catch (IOException | NullPointerException ignored) {
return null;
@@ -76,16 +124,14 @@ public class BlightGame extends SimpleApplication {
@Override
public void simpleInitApp() {
if (splashWindow != null) {
SwingUtilities.invokeLater(() -> { splashWindow.dispose(); splashWindow = null; });
}
status("Lade Tastenbelegung...");
flyCam.setEnabled(false);
inputManager.deleteMapping(INPUT_MAPPING_EXIT);
keyBindings = KeyBindingStore.load();
graphicsSettings = GraphicsStore.load();
status("Baue Spielwelt...");
worldScene = new WorldScene(keyBindings);
stateManager.attach(worldScene);
@@ -147,7 +193,12 @@ public class BlightGame extends SimpleApplication {
}
@Override
public void simpleUpdate(float tpf) {}
public void simpleUpdate(float tpf) {
if (!gameReady) {
gameReady = true; // erstes gerendtertes Frame → Splash schließen
status("Bereit");
}
}
private static Path findProjectRoot() {
String prop = System.getProperty("blight.project.root");

View File

@@ -6,6 +6,7 @@ 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;
@@ -28,6 +29,7 @@ import de.blight.common.MapIO;
import de.blight.common.model.CharacterIO;
import de.blight.common.model.GameCharacter;
import de.blight.common.model.MainCharacter;
import de.blight.game.BlightGame;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.config.KeyBindings;
import de.blight.game.control.PlayerInputControl;
@@ -36,6 +38,8 @@ import com.jme3.post.FilterPostProcessor;
import com.jme3.post.filters.FogFilter;
import com.jme3.water.WaterFilter;
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.WaterBodyState;
import de.blight.game.state.WeatherState;
@@ -91,6 +95,7 @@ public class WorldScene extends BaseAppState {
this.rootNode = this.app.getRootNode();
this.assetManager = app.getAssetManager();
BlightGame.status("Initialisiere Physik-Engine...");
bulletAppState = new BulletAppState();
app.getStateManager().attach(bulletAppState);
@@ -100,17 +105,28 @@ public class WorldScene extends BaseAppState {
@Override
protected void onEnable() {
BlightGame.status("Baue Beleuchtung und Himmel...");
buildLighting();
BlightGame.status("Lade Terrain...");
TerrainQuad terrain = buildTerrain();
BlightGame.status("Lade Vegetation...");
app.getStateManager().attach(new GrassState(terrain));
app.getStateManager().attach(new WaterBodyState(terrain, sharedFPP));
app.getStateManager().attach(new GrassVertexRenderState());
BlightGame.status("Lade Wasserflächen...");
app.getStateManager().attach(new WaterBodyState(sharedFPP));
BlightGame.status("Lade Welt-Objekte...");
app.getStateManager().attach(new RiverState());
app.getStateManager().attach(new WorldObjectsState());
BlightGame.status("Lade Charakter...");
character = loadOrBuildCharacter();
rootNode.attachChild(character);
BlightGame.status("Initialisiere Spieler-Physik...");
// Bullet-Charakter: Kapsel 0.4 Radius, 1.0 Höhe, Y-Achse (1)
CapsuleCollisionShape capsule = new CapsuleCollisionShape(0.4f, 1.0f, 1);
physicsChar = new CharacterControl(capsule, 0.05f);
@@ -128,6 +144,11 @@ public class WorldScene extends BaseAppState {
thirdPersonCam = new ThirdPersonCamera(app.getCamera(), app.getInputManager());
thirdPersonCam.setTarget(character);
MainCharacter mc = findMainCharacter();
if (mc != null) {
app.getStateManager().attach(new LocationState(mc, character));
}
// Maus einfangen keine Klick-Pflicht für Kamerasteuerung
app.getInputManager().setCursorVisible(false);
}
@@ -324,15 +345,17 @@ public class WorldScene extends BaseAppState {
/**
* Erstellt ein Terrain aus der gespeicherten {@link MapData}.
* Die 16385×16385 Editor-Daten werden auf 513×513 heruntergesampelt
* (jeder 32. Vertex), mit Scale (8, 1, 8) auf die gleiche Weltgröße
* 4096 × 4096 WE gebracht.
* 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 GAME_VERTS = 513; // 512 Zellen à 8 WE = 4096 WE
final int STEP = (MapData.TERRAIN_VERTS - 1) / (GAME_VERTS - 1); // 32
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;
@@ -350,13 +373,13 @@ public class WorldScene extends BaseAppState {
+ " → X=" + spawnX + " Z=" + spawnZ);
TerrainQuad terrain = new TerrainQuad("terrain", 65, GAME_VERTS, heights);
terrain.setLocalScale(8f, 1f, 8f);
terrain.setLocalScale(1f, 1f, 1f);
terrain.setShadowMode(RenderQueue.ShadowMode.Receive);
applyTerrainMaterial(terrain, map);
rootNode.attachChild(terrain);
// Terrain-Höhe am Spawnpunkt: lokale Koordinaten = weltXZ / scaleXZ
float terrainH = terrain.getHeight(new Vector2f(spawnX / 8f, spawnZ / 8f));
// 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; }
@@ -364,8 +387,12 @@ public class WorldScene extends BaseAppState {
}
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(
CollisionShapeFactory.createMeshShape(terrain), 0f);
new HeightfieldCollisionShape(heights, terrain.getLocalScale()), 0f);
terrain.addControl(terrainPhysics);
bulletAppState.getPhysicsSpace().add(terrainPhysics);

View File

@@ -0,0 +1,305 @@
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.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;
import java.util.ArrayList;
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.
*/
public class GrassVertexRenderState extends BaseAppState {
// ── Chunks ────────────────────────────────────────────────────────────────
private static final int TERRAIN_HALF = 2048;
private static final int CHUNK_SIZE = 128;
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);
// ── Zustand ───────────────────────────────────────────────────────────────
private Camera cam;
private AssetManager assetManager;
private Node grassNode;
private Material material;
private int nextChunk = 0;
@SuppressWarnings("unchecked")
private final List<GrassVertexBlade>[] chunkBlades = new List[CHUNK_COUNT];
private final Node[] chunkNodes = new Node[CHUNK_COUNT];
@Override
protected void initialize(Application app) {
this.cam = app.getCamera();
this.assetManager = app.getAssetManager();
grassNode = new Node("grassVertexNode");
((SimpleApplication) app).getRootNode().attachChild(grassNode);
for (int i = 0; i < CHUNK_COUNT; i++) chunkBlades[i] = new ArrayList<>();
try {
for (GrassVertexBlade b : GrassVertexIO.load()) {
int ci = chunkIndex(b.x(), b.z());
if (ci >= 0) chunkBlades[ci].add(b);
}
} catch (Exception e) {
System.err.println("[GrassVertexRenderState] Daten nicht ladbar: " + e.getMessage());
}
material = buildMaterial();
}
@Override
protected void cleanup(Application app) {
((SimpleApplication) app).getRootNode().detachChild(grassNode);
}
@Override protected void onEnable() { grassNode.setCullHint(Spatial.CullHint.Inherit); }
@Override protected void onDisable() { grassNode.setCullHint(Spatial.CullHint.Always); }
@Override
public void update(float tpf) {
int built = 0;
while (nextChunk < CHUNK_COUNT && built < INIT_PER_FRAME) {
buildChunk(nextChunk++);
built++;
}
}
// ── Material ──────────────────────────────────────────────────────────────
private Material buildMaterial() {
try {
Material mat = new Material(assetManager, "MatDefs/GrassVertex.j3md");
mat.setFloat("WindSpeed", 1.0f);
mat.setFloat("WindStrength", 0.15f);
mat.setVector3("SunDir", new Vector3f(0.35f, 0.8f, 0.45f).normalizeLocal());
mat.setColor("SunColor", new ColorRGBA(0.95f, 0.90f, 0.75f, 1.0f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
} catch (Exception e) {
System.err.println("[GrassVertexRenderState] Material nicht ladbar: " + e.getMessage());
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.22f, 0.68f, 0.12f, 1f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
}
}
// ── Chunk-Verwaltung ──────────────────────────────────────────────────────
private int chunkIndex(float x, float z) {
int cx = (int) ((x + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int) ((z + TERRAIN_HALF) / CHUNK_SIZE);
if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
return cz * CHUNKS_PER_AXIS + cx;
}
private void buildChunk(int ci) {
List<GrassVertexBlade> blades = chunkBlades[ci];
if (blades.isEmpty()) return;
int bladeCount = blades.size();
int vertCount = bladeCount * BLADES_PER_TUFT * (SEGMENTS + 1) * 2;
int indexCount = bladeCount * BLADES_PER_TUFT * SEGMENTS * 6;
float[] positions = new float[vertCount * 3];
float[] normals = new float[vertCount * 3];
float[] colors = new float[vertCount * 4];
float[] texCoords = new float[vertCount * 2];
int[] indices = new int[indexCount];
int vi = 0, ii = 0;
for (GrassVertexBlade blade : blades) {
buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade);
vi += BLADES_PER_TUFT * (SEGMENTS + 1) * 2;
ii += BLADES_PER_TUFT * SEGMENTS * 6;
}
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.Color, 4, BufferUtils.createFloatBuffer(colors));
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(texCoords));
mesh.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(indices));
mesh.updateBound();
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);
}
// ── Mesh-Logik (gespiegelt zu GrassVertexState) ───────────────────────────
private static void buildTuft(float[] pos, float[] nrm, float[] col, float[] tex, int[] idx,
int vi, int ii, GrassVertexBlade blade) {
float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height();
float baseHW = h * WIDTH_FACTOR * 0.5f;
float tAngle = (float) (((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2));
for (int b = 0; b < BLADES_PER_TUFT; b++) {
float bf = b;
float jitter = (hash(x + bf * 13.7f, z + bf * 19.3f) - 0.5f) * 0.4f;
float bladeAngle = tAngle + b * (float) (Math.PI * 2.0 / BLADES_PER_TUFT) + jitter;
float leanMag = 0.25f + hash(x + bf * 7.3f, z + bf * 3.1f) * 0.55f;
float leanDir = bladeAngle + (float) (Math.PI * 0.5) * (b % 2 == 0 ? 1f : -1f);
float offR = hash(x + bf * 2.9f, z + bf * 5.3f) * 0.28f;
float offA = hash(x + bf * 3.7f, z + bf * 1.7f) * (float) (Math.PI * 2);
float bx = x + offR * (float) Math.cos(offA);
float bz = z + offR * (float) Math.sin(offA);
float cosA = (float) Math.cos(bladeAngle);
float sinA = (float) Math.sin(bladeAngle);
float lnX = (float) (Math.sin(leanMag) * Math.cos(leanDir));
float lnY = (float) Math.cos(leanMag);
float lnZ = (float) (Math.sin(leanMag) * Math.sin(leanDir));
float bendStr = (hash(x + bf * 5.1f, z + bf * 8.7f) - 0.5f) * 2f * BEND_FACTOR * h;
float bendX = -sinA * bendStr;
float bendZ = cosA * bendStr;
int bladeVi = vi + b * (SEGMENTS + 1) * 2;
int bladeIi = ii + b * SEGMENTS * 6;
for (int s = 0; s <= SEGMENTS; s++) {
float t = (float) s / SEGMENTS;
float hw = baseHW * (float) Math.pow(1.0 - t, 1.4);
float spX = bx + lnX * h * t + bendX * t * t;
float spY = y + lnY * h * t;
float spZ = bz + lnZ * h * t + bendZ * t * t;
float tgX = lnX + 2f * bendX * t;
float tgY = lnY;
float tgZ = lnZ + 2f * bendZ * t;
float tgLen = (float) Math.sqrt(tgX*tgX + tgY*tgY + tgZ*tgZ);
if (tgLen > 1e-6f) { tgX /= tgLen; tgY /= tgLen; tgZ /= tgLen; }
float nx = -sinA * tgY;
float ny = sinA * tgX - cosA * tgZ;
float nz = cosA * tgY;
float nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz);
if (nLen > 1e-6f) { nx /= nLen; ny /= nLen; nz /= nLen; }
float blend = 0.30f;
nx *= (1f - blend);
ny = ny * (1f - blend) + blend;
nz *= (1f - blend);
nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz);
if (nLen > 1e-6f) { nx /= nLen; ny /= nLen; nz /= nLen; }
int svi = bladeVi + s * 2;
float dry = blade.dryness();
setV(pos, nrm, col, tex, svi, spX - cosA * hw, spY, spZ - sinA * hw, nx, ny, nz, t, dry);
setV(pos, nrm, col, tex, svi+1, spX + cosA * hw, spY, spZ + sinA * hw, nx, ny, nz, t, dry);
if (s < SEGMENTS) {
int sii = bladeIi + s * 6;
idx[sii] = svi; idx[sii+1] = svi+1; idx[sii+2] = svi+3;
idx[sii+3] = svi; idx[sii+4] = svi+3; idx[sii+5] = svi+2;
}
}
}
}
private static float hash(float a, float b) {
int ia = Float.floatToRawIntBits(a);
int ib = Float.floatToRawIntBits(b);
int h = ia ^ (ib * 0x9e3779b9);
h ^= h >>> 16;
h *= 0x45d9f3b;
h ^= h >>> 16;
return (h & 0x7FFFFFFF) / (float) 0x7FFFFFFF;
}
private static void setV(float[] pos, float[] nrm, float[] col, float[] tex, int vi,
float x, float y, float z, float nx, float ny, float nz,
float wf, float dryness) {
int pi = vi * 3;
pos[pi] = x; pos[pi+1] = y; pos[pi+2] = z;
int ni = vi * 3;
nrm[ni] = nx; nrm[ni+1] = ny; nrm[ni+2] = nz;
int ci = vi * 4;
float gr = ROOT_COLOR.r + (TIP_COLOR.r - ROOT_COLOR.r) * wf;
float gg = ROOT_COLOR.g + (TIP_COLOR.g - ROOT_COLOR.g) * wf;
float gb = ROOT_COLOR.b + (TIP_COLOR.b - ROOT_COLOR.b) * wf;
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;
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,69 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.state.BaseAppState;
import com.jme3.math.Vector3f;
import de.blight.common.LocationIO;
import de.blight.common.model.Location;
import de.blight.common.model.MainCharacter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Verfolgt die Spieler-Position und feuert Location-Trigger wenn der Charakter
* eine Location betritt (Übergang outside→inside).
*/
public class LocationState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(LocationState.class);
private final MainCharacter character;
private List<Location> locations;
private final Set<String> active = new HashSet<>();
/** Referenz auf die Node/Control, von der wir die Spieler-Position lesen. */
private final com.jme3.scene.Node playerNode;
public LocationState(MainCharacter character, com.jme3.scene.Node playerNode) {
this.character = character;
this.playerNode = playerNode;
}
@Override
protected void initialize(Application app) {
try {
locations = LocationIO.load();
log.info("{} Location(s) geladen.", locations.size());
} catch (Exception e) {
log.error("Locations nicht ladbar", e);
locations = List.of();
}
}
@Override
public void update(float tpf) {
if (locations.isEmpty() || playerNode == null) return;
Vector3f pos = playerNode.getWorldTranslation();
float px = pos.x, pz = pos.z;
for (Location loc : locations) {
boolean inside = loc.contains(px, pz);
boolean wasInside = active.contains(loc.getId());
if (inside && !wasInside) {
active.add(loc.getId());
loc.entered(character);
log.debug("Location betreten: {}", loc.getId());
} else if (!inside) {
active.remove(loc.getId());
}
}
}
@Override protected void cleanup(Application app) {}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
}

View File

@@ -8,8 +8,8 @@ import com.jme3.audio.AudioData;
import com.jme3.audio.AudioNode;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import de.blight.common.MusicAreaIO;
import de.blight.common.PlacedMusicArea;
import de.blight.common.AreaIO;
import de.blight.common.PlacedArea;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -36,7 +36,7 @@ public class MusicSystem extends BaseAppState {
private AssetManager assets;
private Node rootNode;
private final List<PlacedMusicArea> data = new ArrayList<>();
private final List<PlacedArea> data = new ArrayList<>();
// three nodes per area: [0]=day, [1]=night, [2]=combat; element may be null
private final List<AudioNode[]> tracks = new ArrayList<>();
private final List<FadeState> fadeStates = new ArrayList<>();
@@ -51,7 +51,7 @@ public class MusicSystem extends BaseAppState {
rootNode = app.getRootNode();
try {
for (PlacedMusicArea area : MusicAreaIO.load()) {
for (PlacedArea area : AreaIO.load()) {
AudioNode[] arr = {
loadAmbient(area.dayTrack()),
loadAmbient(area.nightTrack()),
@@ -131,7 +131,7 @@ public class MusicSystem extends BaseAppState {
checkTimer = 0f;
for (int i = 0; i < data.size(); i++) {
PlacedMusicArea area = data.get(i);
PlacedArea area = data.get(i);
boolean inside = pointInPolygon(playerPos.x, playerPos.z, area.pointsX(), area.pointsZ());
FadeState fs = fadeStates.get(i);

View File

@@ -3,57 +3,38 @@ 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.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.*;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.post.FilterPostProcessor;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import com.jme3.water.WaterFilter;
import de.blight.common.PlacedWater;
import de.blight.common.WaterBodyIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
/**
* Rendert im Editor platzierte Wasserflächen per WaterFilter (visuelle Qualität).
* Ein unsichtbares Komplementär-Mesh (nur Tiefenpuffer) verhindert, dass der
* WaterFilter außerhalb der Flood-Fill-Form Wasser rendert.
* Rendert Polygon-Wasserflächen per WaterPolygonFilter (ein Filter pro Fläche).
* Identisches Aussehen wie WaterFilter, aber auf das eingezeichnete Polygon beschränkt.
*/
public class WaterBodyState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WaterBodyState.class);
private static final int WATER_GRID = 2049;
private static final int STEP = 2;
private static final int WORLD_HALF = 2048;
private static final int MAX_CELLS = 500_000;
private static final float MASK_MARGIN = 20f; // Puffer um den Becken-Radius
private final TerrainQuad terrain;
private final FilterPostProcessor fpp;
private Node waterNode;
private final List<WaterFilter> waterFilters = new ArrayList<>();
private static final Vector3f SUN_DIR = new Vector3f(-0.5f, -1f, -0.5f).normalizeLocal();
public WaterBodyState(TerrainQuad terrain, FilterPostProcessor fpp) {
this.terrain = terrain;
this.fpp = fpp;
private final FilterPostProcessor fpp;
private final List<WaterPolygonFilter> filters = new ArrayList<>();
public WaterBodyState(FilterPostProcessor fpp) {
this.fpp = fpp;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
SimpleApplication sa = (SimpleApplication) app;
waterNode = new Node("waterBodies");
sa.getRootNode().attachChild(waterNode);
List<PlacedWater> bodies;
try {
@@ -64,214 +45,37 @@ public class WaterBodyState extends BaseAppState {
}
if (bodies.isEmpty()) return;
Vector3f sunDir = new Vector3f(-0.5f, -1f, -0.5f).normalizeLocal();
for (PlacedWater body : bodies) {
float[] xs = body.pointsX();
float[] zs = body.pointsZ();
if (xs.length < 3) continue;
try {
Set<Integer> cells = floodFill(body.seedX(), body.seedZ(), body.waterHeight());
if (cells == null || cells.isEmpty()) {
log.warn("Becken nicht rekonstruierbar: {}/{}", body.seedX(), body.seedZ());
continue;
}
float[] cr = computeCentroidAndRadius(cells);
float cx = cr[0], cz = cr[1];
float filterRadius = cr[2] + MASK_MARGIN;
WaterFilter wf = new WaterFilter(sa.getRootNode(), sunDir);
wf.setWaterHeight(body.waterHeight());
wf.setCenter(new Vector3f(cx, body.waterHeight(), cz));
wf.setRadius(filterRadius);
wf.setShapeType(WaterFilter.AreaShape.Circular);
wf.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f));
wf.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f));
wf.setWaterTransparency(0.15f);
wf.setMaxAmplitude(0.3f);
wf.setWaveScale(0.008f);
wf.setSpeed(0.5f);
fpp.addFilter(wf);
waterFilters.add(wf);
// Tiefenpuffer-Maske: alle Zellen im Filterkreis außerhalb des Beckens
Geometry mask = buildDepthMask(cells, cx, cz, filterRadius, body.waterHeight(),
app.getAssetManager());
if (mask != null) waterNode.attachChild(mask);
log.info("Becken: cells={} h={} center=({},{}) r={}",
cells.size(), body.waterHeight(), cx, cz, filterRadius);
WaterPolygonFilter f = new WaterPolygonFilter(
sa.getRootNode(), SUN_DIR, body.waterHeight(), xs, zs);
f.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f));
f.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f));
f.setWaterTransparency(0.15f);
f.setMaxAmplitude(0.1f); // reduced to keep visual height close to waterHeight
f.setWaveScale(0.008f);
f.setSpeed(0.5f);
float rad = (float) Math.toRadians(body.flowDegrees());
f.setWindDirection(new Vector2f((float) Math.sin(rad), (float) Math.cos(rad)));
fpp.addFilter(f);
filters.add(f);
log.info("Wasserfläche geladen: {} Punkte, h={}", xs.length, body.waterHeight());
} catch (Exception e) {
log.error("Fehler bei Becken {}/{}", body.seedX(), body.seedZ(), e);
log.error("Fehler beim Laden einer Wasserfläche", e);
}
}
log.info("{}/{} Wasserfläche(n) geladen.", waterFilters.size(), bodies.size());
log.info("{}/{} Wasserfläche(n) geladen.", filters.size(), bodies.size());
}
@Override
protected void cleanup(Application app) {
for (WaterFilter wf : waterFilters) fpp.removeFilter(wf);
waterFilters.clear();
if (waterNode != null)
((SimpleApplication) app).getRootNode().detachChild(waterNode);
for (WaterPolygonFilter f : filters) fpp.removeFilter(f);
filters.clear();
}
@Override
protected void onEnable() {
if (waterNode != null) waterNode.setCullHint(Spatial.CullHint.Inherit);
}
@Override
protected void onDisable() {
if (waterNode != null) waterNode.setCullHint(Spatial.CullHint.Always);
}
// ── Flood-Fill ────────────────────────────────────────────────────────────
private Set<Integer> floodFill(float seedWorldX, float seedWorldZ, float waterHeight) {
int seedPX = Math.round((seedWorldX + WORLD_HALF) / (float) STEP);
int seedPZ = Math.round((seedWorldZ + WORLD_HALF) / (float) STEP);
seedPX = Math.max(0, Math.min(WATER_GRID - 1, seedPX));
seedPZ = Math.max(0, Math.min(WATER_GRID - 1, seedPZ));
Map<Integer, Float> heightCache = new HashMap<>();
float seedH = sampleHeight(seedPX, seedPZ, heightCache);
if (seedH > waterHeight + 0.5f) {
log.warn("Seed-Höhe {} über waterHeight {} Becken nicht rekonstruierbar", seedH, waterHeight);
return null;
}
Set<Integer> visited = new HashSet<>();
Deque<int[]> queue = new ArrayDeque<>();
visited.add(seedPZ * WATER_GRID + seedPX);
queue.add(new int[]{seedPX, seedPZ});
final int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
while (!queue.isEmpty()) {
int[] c = queue.poll();
int px = c[0], pz = c[1];
if (px == 0 || px == WATER_GRID - 1 || pz == 0 || pz == WATER_GRID - 1)
return null;
for (int[] d : dirs) {
int nx = px + d[0], nz = pz + d[1];
int nIdx = nz * WATER_GRID + nx;
if (visited.contains(nIdx)) continue;
if (sampleHeight(nx, nz, heightCache) <= waterHeight) {
visited.add(nIdx);
if (visited.size() > MAX_CELLS) return null;
queue.add(new int[]{nx, nz});
}
}
}
return visited.isEmpty() ? null : visited;
}
private float sampleHeight(int px, int pz, Map<Integer, Float> cache) {
int key = pz * WATER_GRID + px;
Float cached = cache.get(key);
if (cached != null) return cached;
float worldX = (float)(px * STEP) - WORLD_HALF;
float worldZ = (float)(pz * STEP) - WORLD_HALF;
Float h = terrain.getHeight(new Vector2f(worldX, worldZ));
float height = (h != null && !Float.isNaN(h)) ? h : Float.MAX_VALUE;
cache.put(key, height);
return height;
}
// ── Geometrie-Hilfsmethoden ───────────────────────────────────────────────
private static float[] computeCentroidAndRadius(Set<Integer> cells) {
double sumX = 0, sumZ = 0;
for (int cell : cells) {
sumX += (double)((cell % WATER_GRID) * STEP) - WORLD_HALF;
sumZ += (double)((cell / WATER_GRID) * STEP) - WORLD_HALF;
}
float cx = (float)(sumX / cells.size());
float cz = (float)(sumZ / cells.size());
float maxR = 0f;
for (int cell : cells) {
float wx = (float)((cell % WATER_GRID) * STEP) - WORLD_HALF;
float wz = (float)((cell / WATER_GRID) * STEP) - WORLD_HALF;
float dx = wx - cx, dz = wz - cz;
float r = FastMath.sqrt(dx * dx + dz * dz);
if (r > maxR) maxR = r;
}
return new float[]{cx, cz, maxR};
}
/**
* Erstellt ein unsichtbares Mesh bei waterHeight+0.01 für alle Zellen im
* Filterkreis, die NICHT zum Becken gehören.
*
* Das Mesh schreibt nur in den Tiefenpuffer (ColorWrite=false, DepthWrite=true).
* Vom WaterFilter aus gesehen liegt diese "Fläche" über dem Terrain (höheres Y =
* näher zur Kamera von oben) und blockiert damit das Wasser-Rendering außerhalb
* des Beckens.
*/
private static Geometry buildDepthMask(Set<Integer> basinCells, float cx, float cz,
float radius, float waterHeight,
AssetManager assets) {
float h = waterHeight + 0.01f;
float r2 = radius * radius;
int minPX = Math.max(0, (int) Math.floor(((cx - radius) + WORLD_HALF) / STEP) - 1);
int maxPX = Math.min(WATER_GRID - 1, (int) Math.ceil (((cx + radius) + WORLD_HALF) / STEP) + 1);
int minPZ = Math.max(0, (int) Math.floor(((cz - radius) + WORLD_HALF) / STEP) - 1);
int maxPZ = Math.min(WATER_GRID - 1, (int) Math.ceil (((cz + radius) + WORLD_HALF) / STEP) + 1);
List<Integer> maskCells = new ArrayList<>();
for (int pz = minPZ; pz <= maxPZ; pz++) {
for (int px = minPX; px <= maxPX; px++) {
int cellIdx = pz * WATER_GRID + px;
if (basinCells.contains(cellIdx)) continue;
float wx = (float)(px * STEP) - WORLD_HALF;
float wz = (float)(pz * STEP) - WORLD_HALF;
float dx = wx - cx, dz = wz - cz;
if (dx * dx + dz * dz <= r2) maskCells.add(cellIdx);
}
}
if (maskCells.isEmpty()) return null;
int n = maskCells.size();
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 4 * 3);
IntBuffer idx = BufferUtils.createIntBuffer(n * 6);
int vi = 0;
for (int cell : maskCells) {
int pz = cell / WATER_GRID;
int px = cell % WATER_GRID;
float wx = (float)(px * STEP) - WORLD_HALF;
float wz = (float)(pz * STEP) - WORLD_HALF;
pos.put(wx ).put(h).put(wz );
pos.put(wx + STEP).put(h).put(wz );
pos.put(wx + STEP).put(h).put(wz + STEP);
pos.put(wx ).put(h).put(wz + STEP);
// CCW von oben → Normalen zeigen +Y
idx.put(vi).put(vi+2).put(vi+1);
idx.put(vi).put(vi+3).put(vi+2);
vi += 4;
}
pos.rewind(); idx.rewind();
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
mesh.updateCounts();
Geometry geo = new Geometry("water_mask", mesh);
geo.setShadowMode(RenderQueue.ShadowMode.Off);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Black);
mat.getAdditionalRenderState().setColorWrite(false);
mat.getAdditionalRenderState().setDepthWrite(true);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setMaterial(mat);
// Transparent: renders after Opaque terrain, so terrain depth is already in buffer.
// The mask (closer to camera = higher Y) passes depth test and overwrites it,
// blocking WaterFilter from reading terrain Y < waterHeight at non-basin pixels.
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
return geo;
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
}

View File

@@ -0,0 +1,63 @@
package de.blight.game.state;
import com.jme3.asset.AssetManager;
import com.jme3.material.MatParam;
import com.jme3.material.Material;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Node;
import com.jme3.shader.VarType;
import com.jme3.water.WaterFilter;
import java.util.ArrayList;
import java.util.List;
/**
* WaterFilter mit Polygon-Beschränkung.
* Verwendet exakt denselben Shader wie WaterFilter, erweitert um einen
* GLSL-Point-in-Polygon-Test der das Wasser auf den eingezeichneten Bereich begrenzt.
*/
public class WaterPolygonFilter extends WaterFilter {
private static final int MAX_POINTS = 64;
private final Vector2f[] polygonPoints;
public WaterPolygonFilter(Node scene, Vector3f sunDir,
float waterHeight, float[] pointsX, float[] pointsZ) {
super(scene, sunDir);
setWaterHeight(waterHeight);
int n = Math.min(pointsX.length, MAX_POINTS);
polygonPoints = new Vector2f[n];
for (int i = 0; i < n; i++)
polygonPoints[i] = new Vector2f(pointsX[i], pointsZ[i]);
}
@Override
protected void initFilter(AssetManager manager, RenderManager renderManager,
ViewPort vp, int w, int h) {
super.initFilter(manager, renderManager, vp, w, h);
// WaterFilter set up `material` from Water.j3md — swap to our extended version
// that adds POLYGON_AREA check in the fragment shader.
Material newMat = new Material(manager, "MatDefs/WaterPolygon.j3md");
// Copy every parameter WaterFilter set (textures, floats, vectors, booleans…)
for (MatParam p : new ArrayList<>(material.getParams())) {
try {
newMat.setParam(p.getName(), p.getVarType(), p.getValue());
} catch (Exception ignored) {
// Skip params the new j3md doesn't know about
}
}
// Inject polygon points
newMat.setParam("Points", VarType.Vector2Array, polygonPoints);
newMat.setInt("NumPoints", polygonPoints.length);
// Replace the protected field — all subsequent WaterFilter setters use this field directly
material = newMat;
}
}