feat: Phase 2 Bausystem (Raycast, platzieren/abreißen, Block-Auswahl, Fadenkreuz) + LWJGL arm64-Fix + Gitea CI
All checks were successful
build / build (push) Successful in 1m25s

- LWJGL natives fuer macOS aarch64 (arm64) ergaenzt
- GL.createCapabilities() nach Kontext-Bindung (LWJGL 3 Pflicht)
- VoxelWorld.raycast (DDA), public set()
- PlayerController.overlapsCell (verhindert Platzieren in Spieler)
- Game: Linksklick abbauen, Rechtsklick platzieren, 1-6 Auswahl, Mesh-Rebuild
- Crosshair-Overlay (NDC Screen-Space)
- .gitea/workflows/build.yml (build-on-push, ubuntu-latest)
This commit is contained in:
Jerrit Fritzsche 2026-07-23 17:59:58 +02:00
parent 55d2fad396
commit a4a5cbbf2a
8 changed files with 261 additions and 6 deletions

View File

@ -0,0 +1,21 @@
name: build
on:
push:
branches: [main, dev]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Make gradlew executable
run: chmod +x gradlew
- name: Build (compile + jar)
run: ./gradlew build --no-daemon

View File

@ -19,10 +19,13 @@ Hintergrund weiter — auch wenn es geschlossen ist.
./gradlew build # kompiliert + erstellt Jar
```
## Steuerung (Phase 1)
## Steuerung (Phase 1 + 2)
- **W/A/S/D** — Laufen
- **Maus** — Umsehen (Pointer ist gefangen)
- **Leertaste** — Springen
- **Linksklick** — Block abbauen (vor der Kamera)
- **Rechtsklick** — Block platzieren (leere Zelle davor)
- **1–6** — Block-Auswahl: 1=Gras, 2=Erde, 3=Stein, 4=Holz, 5=Blaetter, 6=Wasser
- **Esc** — Fenster schließen (beendet)
## Projektstruktur

View File

@ -22,10 +22,14 @@ dependencies {
implementation 'org.lwjgl:lwjgl-glfw:3.3.3'
implementation 'org.lwjgl:lwjgl-opengl:3.3.3'
// Native Libs für macOS (universal: arm64 + x86_64)
// Native Libs für macOS — BEIDE Architekturen (x64 + arm64/Apple Silicon).
// LWJGL klassifiziert 'natives-macos' als x64; auf aarch64 JVM braucht es zwingend natives-macos-arm64.
runtimeOnly 'org.lwjgl:lwjgl:3.3.3:natives-macos'
runtimeOnly 'org.lwjgl:lwjgl:3.3.3:natives-macos-arm64'
runtimeOnly 'org.lwjgl:lwjgl-glfw:3.3.3:natives-macos'
runtimeOnly 'org.lwjgl:lwjgl-glfw:3.3.3:natives-macos-arm64'
runtimeOnly 'org.lwjgl:lwjgl-opengl:3.3.3:natives-macos'
runtimeOnly 'org.lwjgl:lwjgl-opengl:3.3.3:natives-macos-arm64'
}
application {

View File

@ -2,17 +2,23 @@ package de.jfritzsche.homegarden.core;
import static org.lwjgl.glfw.GLFW.*;
import static org.lwjgl.opengl.GL11.*;
import static org.lwjgl.opengl.GL.createCapabilities;
import de.jfritzsche.homegarden.gfx.Camera;
import de.jfritzsche.homegarden.gfx.Crosshair;
import de.jfritzsche.homegarden.gfx.Mesh;
import de.jfritzsche.homegarden.gfx.Shader;
import de.jfritzsche.homegarden.math.Mat4;
import de.jfritzsche.homegarden.math.Vec3;
import de.jfritzsche.homegarden.player.PlayerController;
import de.jfritzsche.homegarden.world.Block;
import de.jfritzsche.homegarden.world.VoxelWorld;
/**
* Spielschleife + Rendering. Phase 1: statische Voxelwelt + FP-Laufen.
* Spawn: Mitte der Welt, Fuesse auf der Grasoberflaeche.
* Spielschleife + Rendering.
* Phase 1: statische Voxelwelt + FP-Laufen.
* Phase 2: Bausystem — Raycast, Linksklick abbauen, Rechtsklick platzieren,
* Block-Auswahl 1..6, Mesh-Rebuild, Fadenkreuz.
*/
public final class Game {
private final Window window = new Window();
@ -23,17 +29,23 @@ public final class Game {
private Shader shader;
private Mesh worldMesh;
private Crosshair crosshair;
private int selectedBlock = Block.STONE;
private final int[] placeable = {Block.GRASS, Block.DIRT, Block.STONE, Block.WOOD, Block.LEAVES, Block.WATER};
private static final double SENS = 0.0022;
private static final float FOV = (float) Math.toRadians(70f);
public void run() {
window.create(1280, 720, "HomeGarden");
createCapabilities(); // LWJGL 3: laedt native GL-Funktionen fuer den Kontext
glEnable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE); // einfache Wuerfelflaechen brauchen kein Culling
shader = new Shader(VS, FS);
worldMesh = world.buildMesh();
crosshair = new Crosshair();
player.update(0f, camera, new boolean[5], world); // Kamera an Spawn syncen
@ -45,6 +57,7 @@ public final class Game {
applyMouseLook();
player.update(dt, camera, window.keysForMovement(), world);
handleBuild();
render();
window.pollEvents();
@ -63,6 +76,53 @@ public final class Game {
if (camera.pitch < -lim) camera.pitch = -lim;
}
private void handleBuild() {
int digit = window.consumeDigit();
if (digit >= 1 && digit <= placeable.length) {
selectedBlock = placeable[digit - 1];
System.out.println("[HomeGarden] Block ausgewaehlt: " + blockName(selectedBlock));
}
boolean[] click = window.consumeClicks();
if (!click[0] && !click[1]) return;
int[] hit = new int[3], prev = new int[3];
Vec3 o = new Vec3(camera.position.x, camera.position.y, camera.position.z);
Vec3 d = camera.forward();
if (!world.raycast(o, d, 6f, hit, prev)) return; // ins Leere geschaut
if (click[0]) { // Linksklick: abbauen
world.set(hit[0], hit[1], hit[2], Block.AIR);
rebuildMesh();
} else { // Rechtsklick: platzieren (in der leeren Zelle vor dem Treffer)
int bx = prev[0], by = prev[1], bz = prev[2];
if (!world.isSolid(bx, by, bz)
&& !player.overlapsCell(bx, by, bz)
&& bx >= 0 && by >= 0 && bz >= 0
&& bx < world.sx && by < world.sy && bz < world.sz) {
world.set(bx, by, bz, selectedBlock);
rebuildMesh();
}
}
}
private void rebuildMesh() {
worldMesh.dispose();
worldMesh = world.buildMesh();
}
private String blockName(int id) {
switch (id) {
case Block.GRASS: return "Gras";
case Block.DIRT: return "Erde";
case Block.STONE: return "Stein";
case Block.WOOD: return "Holz";
case Block.LEAVES: return "Blaetter";
case Block.WATER: return "Wasser";
default: return "?";
}
}
private void render() {
glClearColor(0.53f, 0.81f, 0.98f, 1f); // Himmelblau
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
@ -73,10 +133,16 @@ public final class Game {
shader.setMat4("u_proj", proj.toBuffer());
shader.setMat4("u_view", view.toBuffer());
worldMesh.draw();
// Fadenkreuz: ohne Tiefentest, immer oben
glDisable(GL_DEPTH_TEST);
crosshair.draw();
glEnable(GL_DEPTH_TEST);
}
private void cleanup() {
worldMesh.dispose();
crosshair.dispose();
glfwTerminate();
}

View File

@ -7,7 +7,8 @@ import org.lwjgl.glfw.GLFWErrorCallback;
/**
* GLFW-Fenster + Input. Pointer ist gefangen (FP-Look).
* Tastenstatus und Maus-Delta werden fuer den Game-Loop gepuffert.
* Tastenstatus, Maus-Delta, Klick-Events und ausgewaehlte Ziffer werden
* fuer den Game-Loop gepuffert.
*/
public final class Window {
private long handle;
@ -17,6 +18,9 @@ public final class Window {
private boolean firstMouse = true;
private int width = 1280, height = 720;
private final boolean[] clicked = new boolean[2]; // [0]=links, [1]=rechts
private int pendingDigit = -1;
public void create(int w, int h, String title) {
GLFWErrorCallback.createPrint(System.err).set();
if (!glfwInit()) throw new IllegalStateException("glfwInit failed");
@ -42,6 +46,9 @@ public final class Window {
if (key >= 0 && key < keys.length) {
keys[key] = (action == GLFW_PRESS || action == GLFW_REPEAT);
}
if (key >= GLFW_KEY_1 && key <= GLFW_KEY_6 && action == GLFW_PRESS) {
pendingDigit = key - GLFW_KEY_1 + 1;
}
});
glfwSetCursorPosCallback(handle, (win, x, y) -> {
@ -51,6 +58,12 @@ public final class Window {
lastX = x; lastY = y;
});
glfwSetMouseButtonCallback(handle, (win, button, action, mods) -> {
if (action != GLFW_PRESS) return;
if (button == GLFW_MOUSE_BUTTON_LEFT) clicked[0] = true;
else if (button == GLFW_MOUSE_BUTTON_RIGHT) clicked[1] = true;
});
glfwSetFramebufferSizeCallback(handle, (win, ww, hh) -> {
glViewport(0, 0, ww, hh);
width = ww; height = hh;
@ -75,4 +88,16 @@ public final class Window {
out[0] = mouseDX; out[1] = mouseDY;
mouseDX = 0; mouseDY = 0;
}
/** Liefert Links/Rechts-Klick zurueck (edge-triggered, einmalig). */
public boolean[] consumeClicks() {
boolean[] r = new boolean[] {clicked[0], clicked[1]};
clicked[0] = false; clicked[1] = false;
return r;
}
/** Liefert zuletzt gedrueckte Ziffer 1..6 zurueck (oder -1). */
public int consumeDigit() {
int d = pendingDigit; pendingDigit = -1; return d;
}
}

View File

@ -0,0 +1,76 @@
package de.jfritzsche.homegarden.gfx;
import static org.lwjgl.opengl.GL11.*;
import static org.lwjgl.opengl.GL15.*;
import static org.lwjgl.opengl.GL20.*;
import static org.lwjgl.opengl.GL30.*;
/** Fadenkreuz als Screen-Space-Overlay (NDC, kein Tiefentest). */
public final class Crosshair {
private final int vao, vbo, program;
private static final String VS = """
#version 330 core
layout(location = 0) in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
""";
private static final String FS = """
#version 330 core
out vec4 fragColor;
void main() { fragColor = vec4(0.95f, 0.95f, 0.95f, 1.0); }
""";
public Crosshair() {
float t = 0.004f, L = 0.012f; // dicke / laenge
float[] verts = {
-L, -t, L, -t, L, t,
-L, -t, L, t, -L, t,
-t, -L, t, -L, t, L,
-t, -L, t, L, -t, L,
};
program = glCreateProgram();
int vs = compile(GL_VERTEX_SHADER, VS);
int fs = compile(GL_FRAGMENT_SHADER, FS);
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
if (glGetProgrami(program, GL_LINK_STATUS) == 0) {
throw new RuntimeException("Crosshair link failed: " + glGetProgramInfoLog(program));
}
glDeleteShader(vs);
glDeleteShader(fs);
vao = glGenVertexArrays();
glBindVertexArray(vao);
vbo = glGenBuffers();
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, verts, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, false, 0, 0);
glBindVertexArray(0);
}
private static int compile(int type, String src) {
int id = glCreateShader(type);
glShaderSource(id, src);
glCompileShader(id);
if (glGetShaderi(id, GL_COMPILE_STATUS) == 0) {
throw new RuntimeException("Crosshair shader failed: " + glGetShaderInfoLog(id));
}
return id;
}
public void draw() {
glUseProgram(program);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 12);
glBindVertexArray(0);
}
public void dispose() {
glDeleteVertexArrays(vao);
glDeleteBuffers(vbo);
glDeleteProgram(program);
}
}

View File

@ -85,4 +85,14 @@ public final class PlayerController {
}
public Vec3 position() { return pos; }
/** Prueft, ob die Spieler-AABB die Voxelzelle (bx,by,bz) ueberschneidet. */
public boolean overlapsCell(int bx, int by, int bz) {
float minX = pos.x - halfW, maxX = pos.x + halfW;
float minY = pos.y, maxY = pos.y + height;
float minZ = pos.z - halfW, maxZ = pos.z + halfW;
return maxX > bx && minX < bx + 1
&& maxY > by && minY < by + 1
&& maxZ > bz && minZ < bz + 1;
}
}

View File

@ -1,6 +1,7 @@
package de.jfritzsche.homegarden.world;
import de.jfritzsche.homegarden.gfx.Mesh;
import de.jfritzsche.homegarden.math.Vec3;
import java.util.ArrayList;
import java.util.List;
@ -49,7 +50,56 @@ public final class VoxelWorld {
return get(x, y, z) != Block.AIR;
}
private void set(int x, int y, int z, int id) {
/**
* Voxel-Raycast (Amanat/DDA). Start = Kamera, dir = normierte Blickrichtung.
* Liefert bei Treffer die getroffene Zelle in 'hit' und die Zelle davor in
* 'prev' (zum Platzieren). maxDist in Block-Einheiten.
*/
public boolean raycast(Vec3 origin, Vec3 dir, float maxDist, int[] hit, int[] prev) {
int ix = (int) Math.floor(origin.x);
int iy = (int) Math.floor(origin.y);
int iz = (int) Math.floor(origin.z);
float stepX = dir.x > 0 ? 1 : (dir.x < 0 ? -1 : 0);
float stepY = dir.y > 0 ? 1 : (dir.y < 0 ? -1 : 0);
float stepZ = dir.z > 0 ? 1 : (dir.z < 0 ? -1 : 0);
float tDeltaX = dir.x != 0 ? Math.abs(1f / dir.x) : Float.POSITIVE_INFINITY;
float tDeltaY = dir.y != 0 ? Math.abs(1f / dir.y) : Float.POSITIVE_INFINITY;
float tDeltaZ = dir.z != 0 ? Math.abs(1f / dir.z) : Float.POSITIVE_INFINITY;
float tMaxX = nextT(origin.x, ix, stepX, tDeltaX);
float tMaxY = nextT(origin.y, iy, stepY, tDeltaY);
float tMaxZ = nextT(origin.z, iz, stepZ, tDeltaZ);
int px = ix, py = iy, pz = iz;
float t = 0f;
while (t <= maxDist) {
if (isSolid(ix, iy, iz)) {
hit[0] = ix; hit[1] = iy; hit[2] = iz;
prev[0] = px; prev[1] = py; prev[2] = pz;
return true;
}
px = ix; py = iy; pz = iz;
if (tMaxX < tMaxY && tMaxX < tMaxZ) {
t = tMaxX; tMaxX += tDeltaX; ix += stepX;
} else if (tMaxY < tMaxZ) {
t = tMaxY; tMaxY += tDeltaY; iy += stepY;
} else {
t = tMaxZ; tMaxZ += tDeltaZ; iz += stepZ;
}
}
return false;
}
private static float nextT(float o, int i, float step, float tDelta) {
if (step > 0) return (i + 1 - o) * tDelta;
if (step < 0) return (o - i) * tDelta;
return Float.POSITIVE_INFINITY;
}
/** Setzt einen Block (oeffentlich — Bausystem/Platzieren). */
public void set(int x, int y, int z, int id) {
if (x < 0 || y < 0 || z < 0 || x >= sx || y >= sy || z >= sz) return;
blocks[idx(x, y, z)] = (byte) id;
}