diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..3645710 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index 5c1dd0f..f68d977 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.gradle b/build.gradle index 010eae8..1d21540 100644 --- a/build.gradle +++ b/build.gradle @@ -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 { diff --git a/src/main/java/de/jfritzsche/homegarden/core/Game.java b/src/main/java/de/jfritzsche/homegarden/core/Game.java index f7083be..101f826 100644 --- a/src/main/java/de/jfritzsche/homegarden/core/Game.java +++ b/src/main/java/de/jfritzsche/homegarden/core/Game.java @@ -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(); } diff --git a/src/main/java/de/jfritzsche/homegarden/core/Window.java b/src/main/java/de/jfritzsche/homegarden/core/Window.java index 76ecd9e..d23cc31 100644 --- a/src/main/java/de/jfritzsche/homegarden/core/Window.java +++ b/src/main/java/de/jfritzsche/homegarden/core/Window.java @@ -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; + } } diff --git a/src/main/java/de/jfritzsche/homegarden/gfx/Crosshair.java b/src/main/java/de/jfritzsche/homegarden/gfx/Crosshair.java new file mode 100644 index 0000000..2551ced --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/gfx/Crosshair.java @@ -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); + } +} diff --git a/src/main/java/de/jfritzsche/homegarden/player/PlayerController.java b/src/main/java/de/jfritzsche/homegarden/player/PlayerController.java index 14169a5..cdb9dd7 100644 --- a/src/main/java/de/jfritzsche/homegarden/player/PlayerController.java +++ b/src/main/java/de/jfritzsche/homegarden/player/PlayerController.java @@ -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; + } } diff --git a/src/main/java/de/jfritzsche/homegarden/world/VoxelWorld.java b/src/main/java/de/jfritzsche/homegarden/world/VoxelWorld.java index 7da3583..3c4c769 100644 --- a/src/main/java/de/jfritzsche/homegarden/world/VoxelWorld.java +++ b/src/main/java/de/jfritzsche/homegarden/world/VoxelWorld.java @@ -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; }