feat: Phase 1 — Voxel-Engine Gerüst (Fenster, Game-Loop, Voxel-Render, First-Person-Laufen)

This commit is contained in:
Jerrit Fritzsche 2026-07-23 17:40:43 +02:00
commit 55d2fad396
20 changed files with 1071 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.gradle/
build/
out/
.idea/
*.iml
*.ipr
*.iws
.DS_Store

49
README.md Normal file
View File

@ -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

38
build.gradle Normal file
View File

@ -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'
}

3
gradle.properties Normal file
View File

@ -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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -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

248
gradlew vendored Executable file
View File

@ -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" "$@"

82
gradlew.bat vendored Normal file
View File

@ -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%

1
settings.gradle Normal file
View File

@ -0,0 +1 @@
rootProject.name = 'homegarden'

View File

@ -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();
}
}

View File

@ -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);
}
""";
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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; }
}

View File

@ -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
}
}
}

View File

@ -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<Float> pos = new ArrayList<>();
List<Float> col = new ArrayList<>();
List<Integer> 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);
}
}