commit 55d2fad39692a0eec7942e04b69af55a51af5afc Author: Jerrit Fritzsche Date: Thu Jul 23 17:40:43 2026 +0200 feat: Phase 1 — Voxel-Engine Gerüst (Fenster, Game-Loop, Voxel-Render, First-Person-Laufen) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..181b2ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +out/ +.idea/ +*.iml +*.ipr +*.iws +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c1dd0f --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# HomeGarden + +First-Person-Farmspiel mit Voxelgrafik. Der Spieler besitzt einen eigenen Garten, +kümmert sich täglich um Pflanzen, baut Blumenkästen/Gewächshäuser und verdient +Geld über einen Marktplatz. Das Wachstum orientiert sich an der Realität, ist aber +für ein Spiel beschleunigt (Stunden bis Tage). Das Spiel läuft über Timestamps im +Hintergrund weiter — auch wenn es geschlossen ist. + +## Tech-Stack +- **Java 21** (LTS) +- **LWJGL 3.3.3** (OpenGL 3.3 Core + GLFW) — Grafik/Input-Layer +- **Gradle** (Wrapper, kein lokales Gradle nötig) +- Eigene Engine: Renderer, Voxel-Welt, Physik, Spielschleife selbst geschrieben + +## Build & Run +```bash +# JDK 21 vorausgesetzt (brew install openjdk@21) +./gradlew run # startet das Spiel (Fenster öffnet sich) +./gradlew build # kompiliert + erstellt Jar +``` + +## Steuerung (Phase 1) +- **W/A/S/D** — Laufen +- **Maus** — Umsehen (Pointer ist gefangen) +- **Leertaste** — Springen +- **Esc** — Fenster schließen (beendet) + +## Projektstruktur +``` +src/main/java/de/jfritzsche/homegarden/ + Main.java # Einstiegspunkt + core/Game.java # Spielschleife + Rendering + core/Window.java # GLFW-Fenster + Input + gfx/Shader.java # Shader-Compile/Link + gfx/Mesh.java # VAO/VBO-Wrapper + gfx/Camera.java # First-Person-Kamera + math/Vec3.java # Vektor-Mathematik + math/Mat4.java # Matrix-Mathematik (perspective/lookAt) + world/Block.java # Blocktypen + Farben + world/VoxelWorld.java # Fixed-Grid-Welt + Mesh-Generierung + player/PlayerController.java # Bewegung + Gravitation + Kollision +``` + +## Roadmap (Phasen) +1. **MVP-Engine** (aktuell): Fenster, Loop, Voxel-Render, FP-Laufen + Kollision +2. **Interaktion**: Blöcke platzieren/entfernen (Raycasting), Bausystem-Grundgerüst +3. **Pflanzen-Simulation**: Wachstumsstufen, Wasser/Health, Offline-Timestamp-Berechnung +4. **Ökonomie**: Marktplatz, Inventar, Werkzeug, Kaufen/Verkaufen, Geld +5. **Polish**: HUD/UI, Audio, Speicherung, Menüs diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..010eae8 --- /dev/null +++ b/build.gradle @@ -0,0 +1,38 @@ +plugins { + id 'java' + id 'application' +} + +group = 'de.jfritzsche' +version = '0.1.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // LWJGL 3.3.3 — Grafik/Input-Layer (OpenGL + GLFW). Wir bauen Renderer/Welt/Physik selbst. + implementation 'org.lwjgl:lwjgl:3.3.3' + 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) + runtimeOnly 'org.lwjgl:lwjgl:3.3.3:natives-macos' + runtimeOnly 'org.lwjgl:lwjgl-glfw:3.3.3:natives-macos' + runtimeOnly 'org.lwjgl:lwjgl-opengl:3.3.3:natives-macos' +} + +application { + mainClass = 'de.jfritzsche.homegarden.Main' +} + +// GLFW auf macOS braucht die Event-Loop auf dem ersten Thread. +run { + jvmArgs '-XstartOnFirstThread' +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..df0edf5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.java.installations.paths=/opt/homebrew/opt/openjdk@21 +org.gradle.java.installations.auto-download=true +org.gradle.daemon=false diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b8fb7d2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e4d16c1 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'homegarden' diff --git a/src/main/java/de/jfritzsche/homegarden/Main.java b/src/main/java/de/jfritzsche/homegarden/Main.java new file mode 100644 index 0000000..14e6a19 --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/Main.java @@ -0,0 +1,13 @@ +package de.jfritzsche.homegarden; + +import de.jfritzsche.homegarden.core.Game; + +/** + * Einstiegspunkt. Erzeugt die Game-Instanz und startet die Spielschleife. + * Auf macOS muss die GLFW-Event-Loop auf dem ersten Thread laufen (siehe run-Task). + */ +public final class Main { + public static void main(String[] args) { + new Game().run(); + } +} diff --git a/src/main/java/de/jfritzsche/homegarden/core/Game.java b/src/main/java/de/jfritzsche/homegarden/core/Game.java new file mode 100644 index 0000000..f7083be --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/core/Game.java @@ -0,0 +1,104 @@ +package de.jfritzsche.homegarden.core; + +import static org.lwjgl.glfw.GLFW.*; +import static org.lwjgl.opengl.GL11.*; + +import de.jfritzsche.homegarden.gfx.Camera; +import de.jfritzsche.homegarden.gfx.Mesh; +import de.jfritzsche.homegarden.gfx.Shader; +import de.jfritzsche.homegarden.math.Mat4; +import de.jfritzsche.homegarden.player.PlayerController; +import de.jfritzsche.homegarden.world.VoxelWorld; + +/** + * Spielschleife + Rendering. Phase 1: statische Voxelwelt + FP-Laufen. + * Spawn: Mitte der Welt, Fuesse auf der Grasoberflaeche. + */ +public final class Game { + private final Window window = new Window(); + private final VoxelWorld world = new VoxelWorld(32, 20, 32); + private final PlayerController player = + new PlayerController(world.sx / 2, world.surface + 1, world.sz / 2); + private final Camera camera = new Camera(); + + private Shader shader; + private Mesh worldMesh; + + private static final double SENS = 0.0022; + private static final float FOV = (float) Math.toRadians(70f); + + public void run() { + window.create(1280, 720, "HomeGarden"); + glEnable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); // einfache Wuerfelflaechen brauchen kein Culling + + shader = new Shader(VS, FS); + worldMesh = world.buildMesh(); + + player.update(0f, camera, new boolean[5], world); // Kamera an Spawn syncen + + long last = System.nanoTime(); + while (!window.shouldClose()) { + long now = System.nanoTime(); + float dt = Math.min((now - last) / 1e9f, 0.05f); // dt clampen (Tab-Wechsel) + last = now; + + applyMouseLook(); + player.update(dt, camera, window.keysForMovement(), world); + render(); + + window.pollEvents(); + window.swap(); + } + cleanup(); + } + + private void applyMouseLook() { + double[] md = new double[2]; + window.consumeMouseDelta(md); + camera.yaw -= md[0] * SENS; // Maus rechts -> Blick rechts + camera.pitch -= md[1] * SENS; // Maus hoch -> Blick hoch + float lim = (float) (Math.PI / 2 - 0.05); + if (camera.pitch > lim) camera.pitch = lim; + if (camera.pitch < -lim) camera.pitch = -lim; + } + + private void render() { + glClearColor(0.53f, 0.81f, 0.98f, 1f); // Himmelblau + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + shader.use(); + Mat4 proj = Mat4.perspective(FOV, (float) window.getAspect(), 0.1f, 1000f); + Mat4 view = camera.viewMatrix(); + shader.setMat4("u_proj", proj.toBuffer()); + shader.setMat4("u_view", view.toBuffer()); + worldMesh.draw(); + } + + private void cleanup() { + worldMesh.dispose(); + glfwTerminate(); + } + + private static final String VS = """ + #version 330 core + layout(location = 0) in vec3 aPos; + layout(location = 1) in vec3 aColor; + uniform mat4 u_proj; + uniform mat4 u_view; + out vec3 vColor; + void main() { + gl_Position = u_proj * u_view * vec4(aPos, 1.0); + vColor = aColor; + } + """; + + private static final String FS = """ + #version 330 core + in vec3 vColor; + out vec4 fragColor; + void main() { + fragColor = vec4(vColor, 1.0); + } + """; +} diff --git a/src/main/java/de/jfritzsche/homegarden/core/Window.java b/src/main/java/de/jfritzsche/homegarden/core/Window.java new file mode 100644 index 0000000..76ecd9e --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/core/Window.java @@ -0,0 +1,78 @@ +package de.jfritzsche.homegarden.core; + +import static org.lwjgl.glfw.GLFW.*; +import static org.lwjgl.opengl.GL11.*; + +import org.lwjgl.glfw.GLFWErrorCallback; + +/** + * GLFW-Fenster + Input. Pointer ist gefangen (FP-Look). + * Tastenstatus und Maus-Delta werden fuer den Game-Loop gepuffert. + */ +public final class Window { + private long handle; + private final boolean[] keys = new boolean[GLFW_KEY_LAST + 1]; + private double mouseDX = 0, mouseDY = 0; + private double lastX = 0, lastY = 0; + private boolean firstMouse = true; + private int width = 1280, height = 720; + + public void create(int w, int h, String title) { + GLFWErrorCallback.createPrint(System.err).set(); + if (!glfwInit()) throw new IllegalStateException("glfwInit failed"); + + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); // macOS Pflicht + glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE); + + handle = glfwCreateWindow(w, h, title, 0, 0); + if (handle == 0) throw new RuntimeException("Window creation failed"); + + width = w; height = h; + glfwMakeContextCurrent(handle); + glfwSwapInterval(1); // VSync + glfwSetInputMode(handle, GLFW_CURSOR, GLFW_CURSOR_DISABLED); + + glfwSetKeyCallback(handle, (win, key, scan, action, mods) -> { + if (key == GLFW_KEY_ESCAPE && action == GLFW_RELEASE) { + glfwSetWindowShouldClose(win, true); + } + if (key >= 0 && key < keys.length) { + keys[key] = (action == GLFW_PRESS || action == GLFW_REPEAT); + } + }); + + glfwSetCursorPosCallback(handle, (win, x, y) -> { + if (firstMouse) { lastX = x; lastY = y; firstMouse = false; return; } + mouseDX += x - lastX; + mouseDY += y - lastY; + lastX = x; lastY = y; + }); + + glfwSetFramebufferSizeCallback(handle, (win, ww, hh) -> { + glViewport(0, 0, ww, hh); + width = ww; height = hh; + }); + } + + public void pollEvents() { glfwPollEvents(); } + public void swap() { glfwSwapBuffers(handle); } + public boolean shouldClose() { return glfwWindowShouldClose(handle); } + public double getAspect() { return (double) width / (double) height; } + + /** Bewegungstasten: [W, S, A, D, Space]. */ + public boolean[] keysForMovement() { + return new boolean[] { + keys[GLFW_KEY_W], keys[GLFW_KEY_S], keys[GLFW_KEY_A], + keys[GLFW_KEY_D], keys[GLFW_KEY_SPACE] + }; + } + + /** Liefert akkumuliertes Maus-Delta zurueck und setzt es zurueck. */ + public void consumeMouseDelta(double[] out) { + out[0] = mouseDX; out[1] = mouseDY; + mouseDX = 0; mouseDY = 0; + } +} diff --git a/src/main/java/de/jfritzsche/homegarden/gfx/Camera.java b/src/main/java/de/jfritzsche/homegarden/gfx/Camera.java new file mode 100644 index 0000000..07fd272 --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/gfx/Camera.java @@ -0,0 +1,28 @@ +package de.jfritzsche.homegarden.gfx; + +import de.jfritzsche.homegarden.math.Mat4; +import de.jfritzsche.homegarden.math.Vec3; + +/** + * First-Person-Kamera. yaw=0 schaut Richtung -Z. + * pitch wird vom Controller geclamped (knapp unter +/-90 Grad). + */ +public final class Camera { + public final Vec3 position = new Vec3(0, 0, 0); + public float yaw = 0f; + public float pitch = 0f; + + public Vec3 forward() { + float cp = (float) Math.cos(pitch); + return new Vec3( + -(float) Math.sin(yaw) * cp, + (float) Math.sin(pitch), + -(float) Math.cos(yaw) * cp + ); + } + + public Mat4 viewMatrix() { + Vec3 center = position.add(forward()); + return Mat4.lookAt(position, center, new Vec3(0, 1, 0)); + } +} diff --git a/src/main/java/de/jfritzsche/homegarden/gfx/Mesh.java b/src/main/java/de/jfritzsche/homegarden/gfx/Mesh.java new file mode 100644 index 0000000..02d9f94 --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/gfx/Mesh.java @@ -0,0 +1,47 @@ +package de.jfritzsche.homegarden.gfx; + +import static org.lwjgl.opengl.GL15.*; +import static org.lwjgl.opengl.GL20.*; +import static org.lwjgl.opengl.GL30.*; + +/** Statischer Mesh-Wrapper: Position (loc 0) + Farbe (loc 1) + Indexbuffer. */ +public final class Mesh { + private final int vao, vbo, cbo, ebo, indexCount; + + public Mesh(float[] positions, float[] colors, int[] indices) { + indexCount = indices.length; + vao = glGenVertexArrays(); + glBindVertexArray(vao); + + vbo = glGenBuffers(); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, positions, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, false, 0, 0); + + cbo = glGenBuffers(); + glBindBuffer(GL_ARRAY_BUFFER, cbo); + glBufferData(GL_ARRAY_BUFFER, colors, GL_STATIC_DRAW); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, false, 0, 0); + + ebo = glGenBuffers(); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices, GL_STATIC_DRAW); + + glBindVertexArray(0); + } + + public void draw() { + glBindVertexArray(vao); + glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, 0); + glBindVertexArray(0); + } + + public void dispose() { + glDeleteVertexArrays(vao); + glDeleteBuffers(vbo); + glDeleteBuffers(cbo); + glDeleteBuffers(ebo); + } +} diff --git a/src/main/java/de/jfritzsche/homegarden/gfx/Shader.java b/src/main/java/de/jfritzsche/homegarden/gfx/Shader.java new file mode 100644 index 0000000..db56b68 --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/gfx/Shader.java @@ -0,0 +1,40 @@ +package de.jfritzsche.homegarden.gfx; + +import static org.lwjgl.opengl.GL20.*; + +/** Kleiner Shader-Wrapper: kompiliert/linkt VS+FS und setzt Mat4-Uniforms. */ +public final class Shader { + private final int program; + + public Shader(String vsSrc, String fsSrc) { + int vs = compile(GL_VERTEX_SHADER, vsSrc); + int fs = compile(GL_FRAGMENT_SHADER, fsSrc); + program = glCreateProgram(); + glAttachShader(program, vs); + glAttachShader(program, fs); + glLinkProgram(program); + if (glGetProgrami(program, GL_LINK_STATUS) == 0) { + throw new RuntimeException("Shader link failed: " + glGetProgramInfoLog(program)); + } + glDeleteShader(vs); + glDeleteShader(fs); + } + + 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("Shader compile failed: " + glGetShaderInfoLog(id)); + } + return id; + } + + public void use() { glUseProgram(program); } + + public int getUniform(String name) { return glGetUniformLocation(program, name); } + + public void setMat4(String name, java.nio.FloatBuffer buf) { + glUniformMatrix4fv(getUniform(name), false, buf); + } +} diff --git a/src/main/java/de/jfritzsche/homegarden/math/Mat4.java b/src/main/java/de/jfritzsche/homegarden/math/Mat4.java new file mode 100644 index 0000000..f79a9cb --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/math/Mat4.java @@ -0,0 +1,62 @@ +package de.jfritzsche.homegarden.math; + +import org.lwjgl.BufferUtils; +import java.nio.FloatBuffer; + +/** + * 4x4-Matrix, column-major (wie OpenGL/LWJGL erwartet). + * Reicht für Projektion + View; für Model reicht Identity (Welt ist already in World-Space). + */ +public final class Mat4 { + public final float[] m = new float[16]; + + public Mat4 identity() { + for (int i = 0; i < 16; i++) m[i] = 0f; + m[0] = m[5] = m[10] = m[15] = 1f; + return this; + } + + /** Rechtshändige Perspektive, NDC-Z in [-1,1] (Default-Tiefenbereich). */ + public static Mat4 perspective(float fovyRad, float aspect, float near, float far) { + Mat4 r = new Mat4(); + float f = 1f / (float) Math.tan(fovyRad / 2f); + r.m[0] = f / aspect; + r.m[5] = f; + r.m[10] = (far + near) / (near - far); + r.m[11] = -1f; + r.m[14] = (2f * far * near) / (near - far); + return r; + } + + /** View-Matrix via Look-At. eye = Kamera-Position, center = Blickziel. */ + public static Mat4 lookAt(Vec3 eye, Vec3 center, Vec3 up) { + Vec3 z = eye.sub(center).normalize(); // zeigt vom Ziel weg + Vec3 x = up.cross(z).normalize(); + Vec3 y = z.cross(x); + Mat4 r = new Mat4(); + r.m[0] = x.x; r.m[1] = x.y; r.m[2] = x.z; r.m[3] = -x.dot(eye); + r.m[4] = y.x; r.m[5] = y.y; r.m[6] = y.z; r.m[7] = -y.dot(eye); + r.m[8] = z.x; r.m[9] = z.y; r.m[10] = z.z; r.m[11] = -z.dot(eye); + r.m[15] = 1f; + return r; + } + + /** this * o (Spaltenmajor-Multiplikation). */ + public Mat4 mul(Mat4 o) { + Mat4 r = new Mat4(); + for (int col = 0; col < 4; col++) { + for (int row = 0; row < 4; row++) { + float s = 0f; + for (int k = 0; k < 4; k++) s += this.m[k * 4 + row] * o.m[col * 4 + k]; + r.m[col * 4 + row] = s; + } + } + return r; + } + + public FloatBuffer toBuffer() { + FloatBuffer b = BufferUtils.createFloatBuffer(16); + b.put(m).flip(); + return b; + } +} diff --git a/src/main/java/de/jfritzsche/homegarden/math/Vec3.java b/src/main/java/de/jfritzsche/homegarden/math/Vec3.java new file mode 100644 index 0000000..b2c3775 --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/math/Vec3.java @@ -0,0 +1,27 @@ +package de.jfritzsche.homegarden.math; + +/** Minimaler 3D-Vektor (float). Reicht für Kamera, Bewegung und AABB-Kollision. */ +public final class Vec3 { + public float x, y, z; + + public Vec3(float x, float y, float z) { + this.x = x; + this.y = y; + this.z = z; + } + + public Vec3 add(Vec3 o) { return new Vec3(x + o.x, y + o.y, z + o.z); } + public Vec3 sub(Vec3 o) { return new Vec3(x - o.x, y - o.y, z - o.z); } + public Vec3 mul(float s) { return new Vec3(x * s, y * s, z * s); } + public float dot(Vec3 o) { return x * o.x + y * o.y + z * o.z; } + public Vec3 cross(Vec3 o) { + return new Vec3(y * o.z - z * o.y, z * o.x - x * o.z, x * o.y - y * o.x); + } + + public float length() { return (float) Math.sqrt(x * x + y * y + z * z); } + + public Vec3 normalize() { + float l = length(); + return l == 0f ? new Vec3(0, 0, 0) : mul(1f / l); + } +} diff --git a/src/main/java/de/jfritzsche/homegarden/player/PlayerController.java b/src/main/java/de/jfritzsche/homegarden/player/PlayerController.java new file mode 100644 index 0000000..14169a5 --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/player/PlayerController.java @@ -0,0 +1,88 @@ +package de.jfritzsche.homegarden.player; + +import de.jfritzsche.homegarden.gfx.Camera; +import de.jfritzsche.homegarden.math.Vec3; +import de.jfritzsche.homegarden.world.VoxelWorld; + +/** + * First-Person-Bewegung: WASD relativ zur Blickrichtung (nur horizontal), + * Gravitation, Springen, AABB-vs-Voxel-Kollision (pro Achse aufgeloest). + * + * keys[0]=W, [1]=S, [2]=A, [3]=D, [4]=Space + */ +public final class PlayerController { + private final Vec3 pos = new Vec3(16, 0, 16); // Fuesse + private final Vec3 vel = new Vec3(0, 0, 0); + private boolean onGround = false; + + private final float eyeHeight = 1.6f; + private final float halfW = 0.3f; + private final float height = 1.8f; + private final float speed = 5.0f; + private final float gravity = 24f; + private final float jump = 8.2f; + + public PlayerController(int spawnX, int spawnY, int spawnZ) { + pos.x = spawnX; pos.y = spawnY; pos.z = spawnZ; + } + + public void update(float dt, Camera cam, boolean[] keys, VoxelWorld world) { + float sinY = (float) Math.sin(cam.yaw); + float cosY = (float) Math.cos(cam.yaw); + + float mx = 0f, mz = 0f; + if (keys[0]) { mx += -sinY; mz += -cosY; } // W (vor) + if (keys[1]) { mx += sinY; mz += cosY; } // S (zurueck) + if (keys[2]) { mx += -cosY; mz += sinY; } // A (links) + if (keys[3]) { mx += cosY; mz += -sinY; } // D (rechts) + float len = (float) Math.hypot(mx, mz); + if (len > 0f) { mx = mx / len * speed; mz = mz / len * speed; } + vel.x = mx; + vel.z = mz; + + if (keys[4] && onGround) { vel.y = jump; onGround = false; } + vel.y -= gravity * dt; + + moveAxis(dt, world, 0); + moveAxis(dt, world, 1); + moveAxis(dt, world, 2); + + cam.position.x = pos.x; + cam.position.y = pos.y + eyeHeight; + cam.position.z = pos.z; + } + + private void moveAxis(float dt, VoxelWorld world, int axis) { + float d = (axis == 0 ? vel.x : axis == 1 ? vel.y : vel.z) * dt; + if (d == 0f) return; + + float nx = pos.x, ny = pos.y, nz = pos.z; + if (axis == 0) nx += d; else if (axis == 1) ny += d; else nz += d; + + if (collides(nx, ny, nz, world)) { + if (axis == 1) { + if (vel.y < 0f) onGround = true; // auf Boden gelandet + vel.y = 0f; + } + return; // Achse blockiert -> nicht bewegen + } + pos.x = nx; pos.y = ny; pos.z = nz; + if (axis == 1 && d > 0f) onGround = false; // nach oben bewegt + } + + private boolean collides(float x, float y, float z, VoxelWorld world) { + float minX = x - halfW, maxX = x + halfW; + float minY = y, maxY = y + height; + float minZ = z - halfW, maxZ = z + halfW; + int bx0 = (int) Math.floor(minX), bx1 = (int) Math.floor(maxX - 1e-4f); + int by0 = (int) Math.floor(minY), by1 = (int) Math.floor(maxY - 1e-4f); + int bz0 = (int) Math.floor(minZ), bz1 = (int) Math.floor(maxZ - 1e-4f); + for (int bx = bx0; bx <= bx1; bx++) + for (int by = by0; by <= by1; by++) + for (int bz = bz0; bz <= bz1; bz++) + if (world.isSolid(bx, by, bz)) return true; + return false; + } + + public Vec3 position() { return pos; } +} diff --git a/src/main/java/de/jfritzsche/homegarden/world/Block.java b/src/main/java/de/jfritzsche/homegarden/world/Block.java new file mode 100644 index 0000000..761be19 --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/world/Block.java @@ -0,0 +1,18 @@ +package de.jfritzsche.homegarden.world; + +/** Blocktypen und deren Basis-Basisfarbe (RGB, 0..1). Später durch Texturen ersetzbar. */ +public final class Block { + public static final int AIR = 0, GRASS = 1, DIRT = 2, STONE = 3, WOOD = 4, LEAVES = 5, WATER = 6; + + public static float[] colorOf(int id) { + switch (id) { + case GRASS: return new float[]{0.30f, 0.65f, 0.25f}; + case DIRT: return new float[]{0.45f, 0.32f, 0.20f}; + case STONE: return new float[]{0.55f, 0.55f, 0.58f}; + case WOOD: return new float[]{0.50f, 0.34f, 0.18f}; + case LEAVES: return new float[]{0.20f, 0.55f, 0.20f}; + case WATER: return new float[]{0.20f, 0.45f, 0.75f}; + default: return new float[]{1f, 0f, 1f}; // Magenta = Fehler + } + } +} diff --git a/src/main/java/de/jfritzsche/homegarden/world/VoxelWorld.java b/src/main/java/de/jfritzsche/homegarden/world/VoxelWorld.java new file mode 100644 index 0000000..7da3583 --- /dev/null +++ b/src/main/java/de/jfritzsche/homegarden/world/VoxelWorld.java @@ -0,0 +1,128 @@ +package de.jfritzsche.homegarden.world; + +import de.jfritzsche.homegarden.gfx.Mesh; + +import java.util.ArrayList; +import java.util.List; + +/** + * Fixed-Grid-Voxelwelt (endlicher Garten, kein Chunk-Streaming). + * Block (x,y,z) belegt den Würfel [x,x+1] x [y,y+1] x [z,z+1]. + * Die Oberfläche (Gras) liegt bei y = SURFACE; darunter Dirt/Stein. + */ +public final class VoxelWorld { + public final int sx, sy, sz; + public final int surface; + private final byte[] blocks; // idx = x + sx*(y + sy*z) + + // 6 Würfelflächen: 4 Eckpunkte (relativ zum Blockursprung) + private static final float[][][] FACES = { + {{0,1,0},{0,1,1},{1,1,1},{1,1,0}}, // +Y (oben) + {{0,0,0},{1,0,0},{1,0,1},{0,0,1}}, // -Y (unten) + {{1,0,0},{1,0,1},{1,1,1},{1,1,0}}, // +X + {{0,0,0},{0,1,0},{0,1,1},{0,0,1}}, // -X + {{0,0,1},{1,0,1},{1,1,1},{0,1,1}}, // +Z + {{0,0,0},{0,1,0},{1,1,0},{1,0,0}}, // -Z + }; + // Nachbar-Offset je Fläche (damit wir wissen, ob die Fläche sichtbar ist) + private static final int[][] NEIGH = { + {0, 1, 0}, {0, -1, 0}, {1, 0, 0}, {-1, 0, 0}, {0, 0, 1}, {0, 0, -1} + }; + // Simple Fake-Beleuchtung je Fläche (oben hell, unten dunkel) + private static final float[] SHADE = {1.0f, 0.5f, 0.8f, 0.8f, 0.8f, 0.65f}; + + public VoxelWorld(int sx, int sy, int sz) { + this.sx = sx; this.sy = sy; this.sz = sz; + this.surface = sy / 2; + blocks = new byte[sx * sy * sz]; + generate(); + } + + private int idx(int x, int y, int z) { return x + sx * (y + sy * z); } + + public int get(int x, int y, int z) { + if (x < 0 || y < 0 || z < 0 || x >= sx || y >= sy || z >= sz) return Block.AIR; + return blocks[idx(x, y, z)] & 0xFF; + } + + public boolean isSolid(int x, int y, int z) { + return get(x, y, z) != Block.AIR; + } + + private 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; + } + + private void generate() { + for (int z = 0; z < sz; z++) { + for (int x = 0; x < sx; x++) { + for (int y = 0; y < sy; y++) { + int id; + if (y == surface) id = Block.GRASS; + else if (y >= surface - 3) id = Block.DIRT; + else id = Block.STONE; + set(x, y, z, id); + } + } + } + // Ein paar Bäume als Orientierung + int[][] trees = {{6, 6}, {22, 10}, {12, 24}, {26, 26}}; + for (int[] t : trees) { + int tx = t[0], tz = t[1]; + for (int h = 1; h <= 3; h++) set(tx, surface + h, tz, Block.WOOD); + for (int dy = 3; dy <= 5; dy++) { + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + int lx = tx + dx, ly = surface + dy, lz = tz + dz; + if (get(lx, ly, lz) == Block.AIR) set(lx, ly, lz, Block.LEAVES); + } + } + } + } + } + + /** Baut ein einziges Mesh aus allen sichtbaren Blockflächen (nur Flächen gegen Luft). */ + public Mesh buildMesh() { + List pos = new ArrayList<>(); + List col = new ArrayList<>(); + List ind = new ArrayList<>(); + + for (int z = 0; z < sz; z++) { + for (int y = 0; y < sy; y++) { + for (int x = 0; x < sx; x++) { + int id = get(x, y, z); + if (id == Block.AIR) continue; + float[] base = Block.colorOf(id); + for (int f = 0; f < 6; f++) { + int nx = x + NEIGH[f][0]; + int ny = y + NEIGH[f][1]; + int nz = z + NEIGH[f][2]; + if (get(nx, ny, nz) != Block.AIR) continue; // Fläche verdeckt + float shade = SHADE[f]; + int baseIdx = pos.size() / 3; + for (int v = 0; v < 4; v++) { + pos.add(x + FACES[f][v][0]); + pos.add(y + FACES[f][v][1]); + pos.add(z + FACES[f][v][2]); + col.add(base[0] * shade); + col.add(base[1] * shade); + col.add(base[2] * shade); + } + ind.add(baseIdx); ind.add(baseIdx + 1); ind.add(baseIdx + 2); + ind.add(baseIdx); ind.add(baseIdx + 2); ind.add(baseIdx + 3); + } + } + } + } + + float[] p = new float[pos.size()]; + for (int i = 0; i < p.length; i++) p[i] = pos.get(i); + float[] c = new float[col.size()]; + for (int i = 0; i < c.length; i++) c[i] = col.get(i); + int[] idxArr = new int[ind.size()]; + for (int i = 0; i < idxArr.length; i++) idxArr[i] = ind.get(i); + + return new Mesh(p, c, idxArr); + } +}