12 Commits

Author SHA1 Message Date
7e01fc8bd4 refactor 2025-11-15 17:14:22 +01:00
9e86abe60b improve textures 2025-11-15 13:26:23 +01:00
dbbcd7f926 part refactor settings logic 2025-11-15 01:15:43 +01:00
a951b3bcb2 implement view bobbing 2025-11-15 01:01:07 +01:00
eb1542e161 improve debug hud 2025-11-15 00:40:21 +01:00
94fefa6d2c fix collisions and movement 2025-11-15 00:25:06 +01:00
0c726ed416 implement world factory 2025-11-14 23:18:16 +01:00
7a599ab1e5 refactor world logic 2025-11-14 22:35:33 +01:00
398f1fa26f implement full day-night cycle 2025-11-14 22:10:28 +01:00
db8d78043f improve menus 2025-11-14 20:20:21 +01:00
edc983091b refactor menu logic 2025-11-14 20:06:19 +01:00
6868f53e38 implement basic pause menu 2025-11-14 19:17:04 +01:00
26 changed files with 2010 additions and 718 deletions

BIN
assets/textures/dirt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
assets/textures/grass.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

BIN
assets/textures/moss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -1,233 +0,0 @@
package wtf.beatrice.retrorender;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.PerspectiveCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.g3d.Environment;
import com.badlogic.gdx.graphics.g3d.ModelBatch;
import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute;
import com.badlogic.gdx.graphics.g3d.environment.DirectionalShadowLight;
import com.badlogic.gdx.graphics.g3d.utils.DepthShaderProvider;
import com.badlogic.gdx.graphics.glutils.FrameBuffer;
import wtf.beatrice.retrorender.engine.DebugHud;
import wtf.beatrice.retrorender.engine.FpsCameraController;
import wtf.beatrice.retrorender.engine.World3D;
public class GameScreen implements Screen {
private final Main game;
private PerspectiveCamera camera;
private ModelBatch modelBatch;
private Environment environment;
private FpsCameraController cameraController;
private World3D world;
private DebugHud hud;
// Shadow Mapping
private ModelBatch shadowBatch;
private DirectionalShadowLight shadowLight;
// Retro rendering
private FrameBuffer frameBuffer;
private TextureRegion frameRegion;
private SpriteBatch screenBatch;
private static final int RETRO_WIDTH = 320;
private static final int RETRO_HEIGHT = 240;
private boolean showHud = false;
public GameScreen(Main game) {
this.game = game;
}
private void initCamera() {
camera = new PerspectiveCamera(
67f,
RETRO_WIDTH,
RETRO_HEIGHT
);
// near/far + initial direction handled in controller constructor
cameraController = new FpsCameraController(camera);
}
private void initEnvironment() {
environment = new Environment();
// ambient color
environment.set(
new ColorAttribute(ColorAttribute.AmbientLight,
0.4f, 0.4f, 0.5f, 1f)
);
// shadow-casting directional light
shadowLight = new DirectionalShadowLight(
1024, 1024, // shadow map resolution
60f, 60f, // viewport size
1f, 50f // near/far for the light camera
);
shadowLight.set(
1.0f, 0.85f, 0.9f, // casting light color
-0.7f, -1.0f, -0.3f // direction
);
environment.add(shadowLight);
environment.shadowMap = shadowLight;
shadowBatch = new ModelBatch(new DepthShaderProvider());
}
private void initWorld() {
world = new World3D();
}
private void initHud() {
hud = new DebugHud();
}
// --- screen methods
private void initRetroBuffer() {
// RGB + depth
frameBuffer = new FrameBuffer(
com.badlogic.gdx.graphics.Pixmap.Format.RGBA8888,
RETRO_WIDTH,
RETRO_HEIGHT,
true
);
Texture fbTex = frameBuffer.getColorBufferTexture();
frameRegion = new TextureRegion(fbTex);
fbTex.setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest);
// FBO textures are Y-flipped in libGDX, so flip once here
frameRegion.flip(false, true);
screenBatch = new com.badlogic.gdx.graphics.g2d.SpriteBatch();
}
@Override
public void show() {
modelBatch = new ModelBatch();
initCamera();
initEnvironment();
initWorld();
initHud();
initRetroBuffer();
cameraController.onShow();
}
@Override
public void render(float delta) {
cameraController.update(delta, world);
world.update(delta);
if (Gdx.input.isKeyJustPressed(Input.Keys.TAB)) {
showHud = !showHud;
}
// --- shadow pass: render depth from light's point of view
// point to center the shadow camera on;
// can be adjusted this later (e.g. follow player).
shadowLight.begin(new com.badlogic.gdx.math.Vector3(0f, 0f, 0f), camera.direction);
shadowBatch.begin(shadowLight.getCamera());
world.render(shadowBatch, environment); // depth-only pass
shadowBatch.end();
shadowLight.end();
// --- render scene into low res framebuffer
frameBuffer.begin();
Gdx.gl.glViewport(0, 0, RETRO_WIDTH, RETRO_HEIGHT);
Gdx.gl.glClearColor(0.5f, 0.6f, 1.0f, 1f);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT | GL20.GL_DEPTH_BUFFER_BIT);
camera.update();
modelBatch.begin(camera);
world.render(modelBatch, environment); // DefaultShader uses shadowLight + env.shadowMap
modelBatch.end();
// --- HUD
if (showHud)
hud.render(
RETRO_WIDTH,
RETRO_HEIGHT,
camera,
cameraController.getYaw(),
cameraController.getPitch());
frameBuffer.end();
// -- scale framebuffer to screen, preserve aspect ratio --
int windowW = Gdx.graphics.getWidth(); // logical size
int windowH = Gdx.graphics.getHeight();
int backW = Gdx.graphics.getBackBufferWidth(); // actual GL framebuffer
int backH = Gdx.graphics.getBackBufferHeight();
// compute how much we can scale the FBO into the window
float scale = Math.min(
windowW / (float) RETRO_WIDTH,
windowH / (float) RETRO_HEIGHT
);
// strict integer scaling
// scale = (float)Math.max(1, Math.floor(scale));
int drawW = Math.round(RETRO_WIDTH * scale);
int drawH = Math.round(RETRO_HEIGHT * scale);
// center the image
int offsetX = (windowW - drawW) / 2;
int offsetY = (windowH - drawH) / 2;
// viewport in backbuffer coordinates
Gdx.gl.glViewport(0, 0, backW, backH);
Gdx.gl.glClearColor(0f, 0f, 0f, 1f);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
// projection in window (logical) coordinates
screenBatch.getProjectionMatrix().setToOrtho2D(0, 0, windowW, windowH);
screenBatch.begin();
screenBatch.draw(frameRegion, offsetX, offsetY, drawW, drawH);
screenBatch.end();
}
@Override
public void resize(int width, int height) {
camera.viewportWidth = RETRO_WIDTH;
camera.viewportHeight = RETRO_HEIGHT;
camera.update();
cameraController.resize(width, height);
}
@Override public void pause() { }
@Override public void resume() { }
@Override
public void hide() {
cameraController.onHide();
}
@Override
public void dispose() {
cameraController.dispose();
world.dispose();
modelBatch.dispose();
hud.dispose();
shadowBatch.dispose();
shadowLight.dispose();
}
}

View File

@@ -1,16 +1,7 @@
package wtf.beatrice.retrorender; package wtf.beatrice.retrorender;
import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Game; import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx; import wtf.beatrice.retrorender.engine.render.GameScreen;
import com.badlogic.gdx.graphics.*;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g3d.*;
import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute;
import com.badlogic.gdx.graphics.g3d.environment.DirectionalLight;
import com.badlogic.gdx.graphics.g3d.utils.ModelBuilder;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.utils.ScreenUtils;
/** {@link com.badlogic.gdx.ApplicationListener} implementation shared by all platforms. */ /** {@link com.badlogic.gdx.ApplicationListener} implementation shared by all platforms. */
public class Main extends Game public class Main extends Game

View File

@@ -1,271 +0,0 @@
package wtf.beatrice.retrorender.engine;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.graphics.Cursor;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.PerspectiveCamera;
import com.badlogic.gdx.math.Vector3;
public class FpsCameraController {
private final PerspectiveCamera camera;
// movement + look
private float moveSpeed = 5f; // units / second
private float mouseSensitivity = 0.15f;
private float yaw = 0f;
private float pitch = 0f;
private final float eyeHeight = 1.8f;
private final float jumpSpeed = 12f;
private final float gravity = -50f;
private float velocityY = 0f;
private boolean grounded = false;
// --- approximation toggles
private final boolean retroQuantization = true;
// how coarse the rotation is (0.25f = round to 0.25°)
private final float yawStepDegrees = 0.2f;
private final float pitchStepDegrees = 0.2f;
// how coarse the position is (16f = steps of 1/16 units)
private final float positionQuantize = 64f;
private final Vector3 tmpForward = new Vector3();
private final Vector3 tmpRight = new Vector3();
// mouse capture / centering
private boolean mouseCaptured = true;
private int centerX;
private int centerY;
private int captureWarmupFrames = 0;
private static final int WARMUP_FRAMES = 6; // frames to ignore after capture
private Cursor invisibleCursor;
public FpsCameraController(PerspectiveCamera camera) {
this.camera = camera;
// default camera setup
camera.position.set(0f, eyeHeight, 6f);
camera.near = 0.1f;
camera.far = 100f;
yaw = 180f;
pitch = -15f;
updateCameraDirection();
centerX = Gdx.graphics.getWidth() / 2;
centerY = Gdx.graphics.getHeight() / 2;
grounded = true;
}
/** Call from Screen.show() */
public void onShow() {
mouseCaptured = true;
centerX = Gdx.graphics.getWidth() / 2;
centerY = Gdx.graphics.getHeight() / 2;
Gdx.input.setCursorPosition(centerX, centerY);
ensureInvisibleCursor();
Gdx.graphics.setCursor(invisibleCursor);
captureWarmupFrames = WARMUP_FRAMES;
}
/** Call from Screen.hide() */
public void onHide() {
mouseCaptured = false;
Gdx.graphics.setSystemCursor(Cursor.SystemCursor.Arrow);
}
/** Call from Screen.resize() */
public void resize(int width, int height) {
centerX = width / 2;
centerY = height / 2;
if (mouseCaptured) {
Gdx.input.setCursorPosition(centerX, centerY);
}
// after resize, skip frames to avoid mouse skew
captureWarmupFrames = WARMUP_FRAMES;
}
/** Update each frame with delta time */
public void update(float delta, World3D world) {
// --- ESC: release mouse, stop movement / camera
if (Gdx.input.isKeyJustPressed(Input.Keys.ESCAPE)) {
mouseCaptured = false;
Gdx.graphics.setSystemCursor(Cursor.SystemCursor.Arrow);
}
// only capture inputs if focused/captured
if (!mouseCaptured) {
if (Gdx.input.justTouched()) { // if clicked
mouseCaptured = true;
centerX = Gdx.graphics.getWidth() / 2;
centerY = Gdx.graphics.getHeight() / 2;
Gdx.input.setCursorPosition(centerX, centerY);
ensureInvisibleCursor();
Gdx.graphics.setCursor(invisibleCursor);
captureWarmupFrames = WARMUP_FRAMES;
}
return;
}
// skip a few frames after capture to avoid mouse skew
// teleport cursor to center
if (captureWarmupFrames > 0) {
captureWarmupFrames--;
Gdx.input.setCursorPosition(centerX, centerY);
return;
}
// --- mouse look: keep cursor centred
int mouseX = Gdx.input.getX();
int mouseY = Gdx.input.getY();
int dx = mouseX - centerX;
int dy = mouseY - centerY;
// recenter for next frame
Gdx.input.setCursorPosition(centerX, centerY);
float deltaX = -dx * mouseSensitivity;
float deltaY = -dy * mouseSensitivity;
yaw += deltaX;
pitch += deltaY;
// clamp pitch to avoid flipping
pitch = Math.max(-89f, Math.min(89f, pitch));
// keep yaw in [0, 360)
if (yaw >= 360f) yaw -= 360f;
if (yaw < 0f) yaw += 360f;
updateCameraDirection();
// --- keyboard movement
// forward vector (flattened on XZ)
tmpForward.set(camera.direction.x, 0f, camera.direction.z).nor();
// right vector (perpendicular)
tmpRight.set(tmpForward.z, 0f, -tmpForward.x).nor();
float moveAmount = moveSpeed * delta;
float moveX = 0f;
float moveZ = 0f;
if (Gdx.input.isKeyPressed(Input.Keys.W)) {
moveX += tmpForward.x * moveAmount;
moveZ += tmpForward.z * moveAmount;
}
if (Gdx.input.isKeyPressed(Input.Keys.S)) {
moveX -= tmpForward.x * moveAmount;
moveZ -= tmpForward.z * moveAmount;
}
if (Gdx.input.isKeyPressed(Input.Keys.A)) {
moveX += tmpRight.x * moveAmount;
moveZ += tmpRight.z * moveAmount;
}
if (Gdx.input.isKeyPressed(Input.Keys.D)) {
moveX -= tmpRight.x * moveAmount;
moveZ -= tmpRight.z * moveAmount;
}
float playerRadius = 0.4f;
// --- try to move in X/Z with collision
float newX = camera.position.x + moveX;
float newZ = camera.position.z + moveZ;
float curY = camera.position.y;
if (!world.collidesAt(newX, curY, camera.position.z, playerRadius, eyeHeight)) {
camera.position.x = newX;
}
if (!world.collidesAt(camera.position.x, curY, newZ, playerRadius, eyeHeight)) {
camera.position.z = newZ;
}
boolean jumpHeld = Gdx.input.isKeyPressed(Input.Keys.SPACE);
// apply gravity
velocityY += gravity * delta;
float proposedY = camera.position.y + velocityY * delta;
// feet position if we accept this Y
float feetY = proposedY - eyeHeight;
// find ground at current X/Z
float groundY = world.getGroundHeightAt(camera.position.x, camera.position.z, 0f);
if (feetY <= groundY) {
// ground or other surface below feet
grounded = true;
velocityY = 0f;
camera.position.y = groundY + eyeHeight;
} else {
grounded = false;
camera.position.y = proposedY;
}
// start jump again if holding down key
if (grounded && jumpHeld) {
velocityY = jumpSpeed;
grounded = false;
}
// snap position to quantization steps
if (retroQuantization) {
float q = positionQuantize;
camera.position.x = Math.round(camera.position.x * q) / q;
camera.position.y = Math.round(camera.position.y * q) / q;
camera.position.z = Math.round(camera.position.z * q) / q;
}
}
private void updateCameraDirection() {
// build direction vector from yaw/pitch
float useYaw = yaw;
float usePitch = pitch;
if (retroQuantization) {
// snap yaw/pitch to fixed angular steps
useYaw = Math.round(useYaw / yawStepDegrees) * yawStepDegrees;
usePitch = Math.round(usePitch / pitchStepDegrees) * pitchStepDegrees;
}
float yawRad = (float) Math.toRadians(useYaw);
float pitchRad = (float) Math.toRadians(usePitch);
float x = (float) (Math.cos(pitchRad) * Math.sin(yawRad));
float y = (float) Math.sin(pitchRad);
float z = (float) (Math.cos(pitchRad) * Math.cos(yawRad));
camera.direction.set(x, y, z).nor();
}
private void ensureInvisibleCursor() {
if (invisibleCursor == null) {
Pixmap pm = new Pixmap(16, 16, Pixmap.Format.RGBA8888);
pm.setColor(0, 0, 0, 0);
pm.fill();
invisibleCursor = Gdx.graphics.newCursor(pm, 0, 0);
pm.dispose();
}
}
public void dispose() {
if (invisibleCursor != null) {
invisibleCursor.dispose();
invisibleCursor = null;
}
}
public float getYaw() { return yaw; }
public float getPitch() { return pitch; }
public void setMoveSpeed(float moveSpeed) { this.moveSpeed = moveSpeed; }
public void setMouseSensitivity(float mouseSensitivity) { this.mouseSensitivity = mouseSensitivity; }
}

View File

@@ -1,196 +0,0 @@
package wtf.beatrice.retrorender.engine;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.VertexAttributes;
import com.badlogic.gdx.graphics.g3d.*;
import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute;
import com.badlogic.gdx.graphics.g3d.attributes.TextureAttribute;
import com.badlogic.gdx.graphics.g3d.utils.MeshPartBuilder;
import com.badlogic.gdx.graphics.g3d.utils.ModelBuilder;
import com.badlogic.gdx.math.Vector3;
import java.util.ArrayList;
import java.util.List;
public class World3D {
// textures
private final Texture groundTexture;
// models (base mesh)
private final Model groundModel;
private final Model cubeModel;
// instances
private final ModelInstance groundInstance;
private final List<ModelInstance> cubeInstances = new ArrayList<>();
public World3D() {
ModelBuilder modelBuilder = new ModelBuilder();
// --- load floor texture
groundTexture = new Texture(Gdx.files.internal("textures/paving.png"));
groundTexture.setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest);
groundTexture.setWrap(Texture.TextureWrap.Repeat, Texture.TextureWrap.Repeat);
// --- create ground material
Material groundMat = new Material(
ColorAttribute.createDiffuse(1f, 1f, 1f, 1f),
TextureAttribute.createDiffuse(groundTexture)
);
// plane size (halfSize in X/Z)
float halfSize = 32f;
// times the texture repeats across the whole plane
float tileScale = 16f;
modelBuilder.begin();
MeshPartBuilder mpb = modelBuilder.part(
"ground",
GL20.GL_TRIANGLES,
VertexAttributes.Usage.Position
| VertexAttributes.Usage.Normal
| VertexAttributes.Usage.TextureCoordinates, // 👈 important
groundMat
);
// set UV range so texture repeats tileScale times
mpb.setUVRange(0f, 0f, tileScale, tileScale);
// build the quad
mpb.rect(
-halfSize, 0f, halfSize, // top-left
halfSize, 0f, halfSize, // top-right
halfSize, 0f, -halfSize, // bottom-right
-halfSize, 0f, -halfSize, // bottom-left
0f, 1f, 0f // normal up
);
groundModel = modelBuilder.end();
groundInstance = new ModelInstance(groundModel);
// --- create reusable cube model
cubeModel = modelBuilder.createBox(
2f, 2f, 2f,
new Material(ColorAttribute.createDiffuse(1f, 1f, 1f, 1f)),
VertexAttributes.Usage.Position | VertexAttributes.Usage.Normal
);
// --- place random cubes in the world
// center
cubeInstances.add(new ModelInstance(cubeModel)); // at origin
// four around
cubeInstances.add(new ModelInstance(cubeModel));
cubeInstances.get(1).transform.setToTranslation(8f, 1f, 8f);
cubeInstances.add(new ModelInstance(cubeModel));
cubeInstances.get(2).transform.setToTranslation(-8f, 1f, 8f);
cubeInstances.add(new ModelInstance(cubeModel));
cubeInstances.get(3).transform.setToTranslation(8f, 1f, -8f);
cubeInstances.add(new ModelInstance(cubeModel));
cubeInstances.get(4).transform.setToTranslation(-8f, 1f, -8f);
// one tall pillar in front of you
ModelInstance pillar = new ModelInstance(cubeModel);
pillar.transform
.setToScaling(1f, 3f, 1f)
.translate(0f, 3f, -10f);
cubeInstances.add(pillar);
}
public void update(float delta) {
// rotate cubes around y axis
for (ModelInstance instance : cubeInstances) {
instance.transform.rotate(Vector3.Y, 20f * delta);
}
}
public void render(ModelBatch batch, Environment environment) {
// draw ground
batch.render(groundInstance, environment);
// draw cubes
for (ModelInstance instance : cubeInstances) {
batch.render(instance, environment);
}
}
/**
* Returns the ground height (y) at a given XZ position.
* Starts from baseHeight (e.g. 0) and checks if you're standing on top of any cube.
*/
public float getGroundHeightAt(float x, float z, float baseHeight) {
float maxY = baseHeight; // ground plane at y = 0
// a cube is 2 units tall, from y = 0 to y = 2
float cubeHalfSize = 1f;
float cubeTopY = 2f;
Vector3 tmp = new Vector3();
for (ModelInstance cube : cubeInstances) {
cube.transform.getTranslation(tmp);
float minX = tmp.x - cubeHalfSize;
float maxX = tmp.x + cubeHalfSize;
float minZ = tmp.z - cubeHalfSize;
float maxZ = tmp.z + cubeHalfSize;
if (x >= minX && x <= maxX && z >= minZ && z <= maxZ) {
if (cubeTopY > maxY) {
maxY = cubeTopY;
}
}
}
return maxY;
}
/**
* Simple horizontal collision check between a player and cube sides.
* Player is approximated as a capsule: radius in XZ, height = eyeHeight.
*/
public boolean collidesAt(float x, float y, float z, float radius, float eyeHeight) {
float feetY = y - eyeHeight;
float headY = y;
float cubeHalfSize = 1f;
float cubeBottomY = 0f;
float cubeTopY = 2f;
Vector3 tmp = new Vector3();
for (ModelInstance cube : cubeInstances) {
cube.transform.getTranslation(tmp);
float minX = tmp.x - cubeHalfSize;
float maxX = tmp.x + cubeHalfSize;
float minZ = tmp.z - cubeHalfSize;
float maxZ = tmp.z + cubeHalfSize;
// no vertical overlap -> ignore
if (headY <= cubeBottomY || feetY >= cubeTopY) {
continue;
}
// circle (player) vs AABB (cube) in XZ
boolean overlapX = (x + radius > minX) && (x - radius < maxX);
boolean overlapZ = (z + radius > minZ) && (z - radius < maxZ);
if (overlapX && overlapZ) {
return true;
}
}
return false;
}
public void dispose() {
groundModel.dispose();
cubeModel.dispose();
groundTexture.dispose();
}
}

View File

@@ -0,0 +1,14 @@
package wtf.beatrice.retrorender.engine.config;
public class GameSettings {
public float fov = 60f; // degrees
public float mouseSensitivity = 0.15f;
public float moveSpeed = 5f;
public final float minFov = 40f;
public final float maxFov = 100f;
public final float fovStep = 2f;
public boolean bobbingEnabled = true;
}

View File

@@ -0,0 +1,189 @@
package wtf.beatrice.retrorender.engine.render;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute;
import com.badlogic.gdx.graphics.g3d.environment.DirectionalShadowLight;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector3;
public class DayNightCycle {
private final DirectionalShadowLight sun;
private final ColorAttribute ambientLight;
// base colors
private final Color dayAmbient = new Color(0.6f, 0.7f, 0.7f, 1f);
private final Color nightAmbient = new Color(0.2f, 0.15f, 0.2f, 1f);
private final Color daySunColor = new Color(1.0f, 0.9f, 1f, 1f);
private final Color nightSunColor = new Color(0.4f, 0.4f, 0.6f, 1f);
private final Color daySkyColor = new Color(0.8f, 0.9f, 1.0f, 1f);
private final Color nightSkyColor = new Color(0.1f, 0.01f, 0.2f, 1f);
private final Color currentSkyColor = new Color();
private final Color tmpSunColor = new Color();
private final Vector3 tmpDir = new Vector3();
private final Vector3 sunDirection = new Vector3();
// Rotate around Z so the sun path is tilted relative to world up.
private final Vector3 tiltAxis = new Vector3(0f, 0f, 1f).nor();
private float axialTilt = 30f; // in degrees
/**
* If false (default), simple equator-style model:
* timeOfDay=0 -> midnight
* timeOfDay=0.25-> sunrise
* timeOfDay=0.5 -> noon
* timeOfDay=0.75-> sunset
*
* Brightness is driven purely by this ideal curve; Z-tilt only affects
* visual direction (shadow slant), not timing of day/night.
*
* If true, brightness and day length depend on the final tilted direction,
* so axialTiltDeg affects how long days are and how high the sun gets.
*/
private boolean seasonal = false;
/**
* 01: 0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset.
*/
private float timeOfDay = 0.25f;
/** Seconds for one full 24h cycle. */
private float dayLengthSeconds = 60 * 5f;
/**
* Daylight / altitude factor in [0, 1].
* 0 = "sun below horizon / no direct light"
* 1 = "sun as high/bright as it gets for current configuration".
*/
private float sunAltitude = 0f;
public DayNightCycle(DirectionalShadowLight sun, ColorAttribute ambientLight) {
this.sun = sun;
this.ambientLight = ambientLight;
}
public void update(float delta) {
if (dayLengthSeconds <= 0f) return;
// Advance time of day
timeOfDay = (timeOfDay + delta / dayLengthSeconds) % 1f;
// 0..2π over a day
float angle = timeOfDay * MathUtils.PI2;
// Ideal equator curve:
// t=0 -> midnight (height=-1)
// t=0.25-> sunrise (height=0)
// t=0.5 -> noon (height=1)
// t=0.75-> sunset (height=0)
float orbitAngle = angle - MathUtils.PI / 2f;
float height = MathUtils.sin(orbitAngle); // -1..1
float horizontal = MathUtils.cos(orbitAngle); // -1..1
// Base direction before Z-tilt, as if on equator.
float baseX = -horizontal * 0.7f;
float baseY = -height; // negative when sun is above
float baseZ = -horizontal * 0.3f;
if (!seasonal) {
// === Simple "equator-style" mode ===
float sunFactor = MathUtils.clamp(height, 0f, 1f); // 0..1
sunAltitude = sunFactor; // 👈 always 0..1
float ambientFactor = 0.25f + 0.75f * sunFactor;
// Visual direction with axial tilt
tmpDir.set(baseX, baseY, baseZ).nor();
tmpDir.rotate(tiltAxis, axialTilt);
sunDirection.set(tmpDir);
// Sun color / intensity
tmpSunColor.set(nightSunColor).lerp(daySunColor, sunFactor);
tmpSunColor.mul(sunFactor);
sun.set(tmpSunColor.r, tmpSunColor.g, tmpSunColor.b,
tmpDir.x, tmpDir.y, tmpDir.z);
// Ambient + sky
ambientLight.color.set(nightAmbient).lerp(dayAmbient, ambientFactor);
currentSkyColor.set(nightSkyColor).lerp(daySkyColor, ambientFactor);
} else {
// === Seasonal / physical mode ===
tmpDir.set(baseX, baseY, baseZ).nor();
tmpDir.rotate(tiltAxis, axialTilt);
sunDirection.set(tmpDir);
// True altitude from final direction: 0..1
float altitude = Math.max(-tmpDir.y, 0f);
sunAltitude = altitude; // 👈 also 0..1
float sunFactor = altitude;
float ambientFactor = 0.25f + 0.75f * altitude;
tmpSunColor.set(nightSunColor).lerp(daySunColor, sunFactor);
tmpSunColor.mul(sunFactor);
sun.set(tmpSunColor.r, tmpSunColor.g, tmpSunColor.b,
tmpDir.x, tmpDir.y, tmpDir.z);
ambientLight.color.set(nightAmbient).lerp(dayAmbient, ambientFactor);
currentSkyColor.set(nightSkyColor).lerp(daySkyColor, ambientFactor);
}
}
public Color getSkyColor() {
return currentSkyColor;
}
public float getTimeOfDay() {
return timeOfDay;
}
/**
* Daylight / altitude factor in [0, 1].
* 0 = no direct sunlight, 1 = max noon brightness.
*/
public float getSunAltitude() {
return sunAltitude;
}
public void setTimeOfDay(float t) {
timeOfDay = MathUtils.clamp(t, 0f, 1f);
}
/** World-space direction the sun is shining *from*. */
public Vector3 getSunDirection() {
return sunDirection;
}
/** True if we consider the sun above the horizon (i.e. there should be direct light / shadows). */
public boolean isSunAboveHorizon() {
return sunAltitude > 0f; // strictly > 0; you can use >= 0.01f if you want a tiny cutoff
}
public float getAxialTilt()
{
return axialTilt;
}
public void setAxialTilt(float degrees)
{
axialTilt = degrees;
}
public float getDayLength()
{
return dayLengthSeconds;
}
public void setDayLength(float seconds)
{
dayLengthSeconds = seconds;
}
}

View File

@@ -0,0 +1,387 @@
package wtf.beatrice.retrorender.engine.render;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.graphics.Cursor;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.PerspectiveCamera;
import com.badlogic.gdx.math.Vector3;
import wtf.beatrice.retrorender.engine.config.GameSettings;
import wtf.beatrice.retrorender.engine.world.World3D;
public class FpsCameraController {
private final PerspectiveCamera camera;
private final GameSettings settings;
// movement + look
private float yaw = 0f;
private float pitch = 0f;
private final float eyeHeight = 1.8f;
private final float jumpSpeed = 12f;
private final float gravity = -50f;
private float velocityY = 0f;
private boolean grounded = false;
// view bobbing
private float bobTime = 0f;
private float bobOffsetY = 0f;
private final float bobAmount = 0.04f; // amplitude in world units
private final float bobSpeed = 16f;
// --- approximation toggles
private final boolean retroQuantization = false;
// how coarse the rotation is (0.25f = round to 0.25°)
private final float yawStepDegrees = 0.2f;
private final float pitchStepDegrees = 0.2f;
// how coarse the position is (16f = steps of 1/16 units)
private final float positionQuantize = 64f;
private final Vector3 tmpForward = new Vector3();
private final Vector3 tmpRight = new Vector3();
// mouse capture / centering
private boolean mouseCaptured = true;
private int centerX;
private int centerY;
private int captureWarmupFrames = 0;
private static final int WARMUP_FRAMES = 10; // frames to ignore after capture
private Cursor invisibleCursor;
public FpsCameraController(PerspectiveCamera camera, GameSettings settings) {
this.camera = camera;
this.settings = settings;
camera.position.set(0f, eyeHeight, 6f);
camera.near = 0.1f;
camera.far = 100f;
yaw = 180f;
pitch = -15f;
updateCameraDirection();
centerX = Gdx.graphics.getWidth() / 2;
centerY = Gdx.graphics.getHeight() / 2;
grounded = true;
}
/** Call from Screen.show() */
public void onShow() {
captureMouse();
}
/** Call from Screen.hide() */
public void onHide() {
releaseMouse();
}
/** Explicitly capture mouse (used when resuming from pause). */
public void captureMouse() {
mouseCaptured = true;
centerX = Gdx.graphics.getWidth() / 2;
centerY = Gdx.graphics.getHeight() / 2;
Gdx.input.setCursorPosition(centerX, centerY);
ensureInvisibleCursor();
Gdx.graphics.setCursor(invisibleCursor);
captureWarmupFrames = WARMUP_FRAMES;
}
/** Explicitly release mouse (used when pausing). */
public void releaseMouse() {
mouseCaptured = false;
Gdx.graphics.setSystemCursor(Cursor.SystemCursor.Arrow);
}
/** Call from Screen.resize() */
public void resize(int width, int height) {
centerX = width / 2;
centerY = height / 2;
if (mouseCaptured) {
Gdx.input.setCursorPosition(centerX, centerY);
}
// after resize, skip frames to avoid mouse skew
captureWarmupFrames = WARMUP_FRAMES;
}
/** Update each frame with delta time */
public void update(float delta, World3D world) {
if (settings.bobbingEnabled) {
camera.position.y -= bobOffsetY;
bobOffsetY = 0f;
}
if (!mouseCaptured) {
return;
}
// warmup: ignore mouse for a few frames after capture
if (captureWarmupFrames > 0) {
captureWarmupFrames--;
Gdx.input.setCursorPosition(centerX, centerY);
return;
}
// --- mouse look ---
int mouseX = Gdx.input.getX();
int mouseY = Gdx.input.getY();
int dx = mouseX - centerX;
int dy = mouseY - centerY;
// recenter for next frame
Gdx.input.setCursorPosition(centerX, centerY);
float deltaX = -dx * settings.mouseSensitivity;
float deltaY = -dy * settings.mouseSensitivity;
yaw += deltaX;
pitch += deltaY;
pitch = Math.max(-89f, Math.min(89f, pitch));
if (yaw >= 360f) yaw -= 360f;
if (yaw < 0f) yaw += 360f;
updateCameraDirection();
// --- build movement vectors ---
tmpForward.set(camera.direction.x, 0f, camera.direction.z).nor();
tmpRight.set(tmpForward.z, 0f, -tmpForward.x).nor();
camera.fieldOfView = settings.fov;
float moveAmount = settings.moveSpeed * delta;
float moveX = 0f;
float moveZ = 0f;
if (Gdx.input.isKeyPressed(Input.Keys.W)) {
moveX += tmpForward.x * moveAmount;
moveZ += tmpForward.z * moveAmount;
}
if (Gdx.input.isKeyPressed(Input.Keys.S)) {
moveX -= tmpForward.x * moveAmount;
moveZ -= tmpForward.z * moveAmount;
}
if (Gdx.input.isKeyPressed(Input.Keys.A)) {
moveX += tmpRight.x * moveAmount;
moveZ += tmpRight.z * moveAmount;
}
if (Gdx.input.isKeyPressed(Input.Keys.D)) {
moveX -= tmpRight.x * moveAmount;
moveZ -= tmpRight.z * moveAmount;
}
boolean jumpHeld = Gdx.input.isKeyPressed(Input.Keys.SPACE);
float playerRadius = 0.4f;
// =========================================================
// 1) VERTICAL INTEGRATION + GROUND SNAP (capsule-based)
// =========================================================
// Apply gravity
velocityY += gravity * delta;
float oldY = camera.position.y;
float proposedY = oldY + velocityY * delta;
// Capsule ground at current XZ
float groundY = world.getCapsuleGroundHeight(
camera.position.x,
camera.position.z,
playerRadius,
0f
);
float feetNow = oldY - eyeHeight;
float feetNext = proposedY - eyeHeight;
final float GROUND_SNAP_EPS = 0.15f; // max distance to snap down
if (velocityY <= 0f && feetNext <= groundY + GROUND_SNAP_EPS) {
// Falling or resting, and we reached (or slightly passed) the ground
grounded = true;
velocityY = 0f;
camera.position.y = groundY + eyeHeight;
} else {
grounded = false;
camera.position.y = proposedY;
}
// Handle jump AFTER we've updated grounded state
if (grounded && jumpHeld) {
velocityY = jumpSpeed;
grounded = false;
}
// =========================================================
// 2) HORIZONTAL MOVEMENT WITH COLLISION + OPTIONAL STEP UP
// =========================================================
// We re-query ground at the start of the frame for step logic
float currentGroundY = world.getCapsuleGroundHeight(
camera.position.x,
camera.position.z,
playerRadius,
0f
);
float curY = camera.position.y;
// Max height we auto-step; must be < crate height, < house walls, etc
final float maxStepHeight = 0.6f; // your crate is 1.0 high, so you must jump
final float stepSnapEps = 0.05f; // tolerance when landing on step
// --- X axis ---
if (moveX != 0f) {
float targetX = camera.position.x + moveX;
float targetZ = camera.position.z;
boolean collides = world.collidesAt(
targetX,
curY,
targetZ,
playerRadius,
eyeHeight
);
if (!collides) {
camera.position.x = targetX;
} else if (grounded && velocityY <= 0.01f) {
// Only try to step up if we are actually on the ground (walking),
// NOT while jumping or falling.
float newGroundY = world.getCapsuleGroundHeight(
targetX,
targetZ,
playerRadius,
0f
);
float step = newGroundY - currentGroundY;
if (step > 0f && step <= maxStepHeight) {
// Step up: teleport onto the higher surface
camera.position.x = targetX;
camera.position.y = newGroundY + eyeHeight + stepSnapEps;
curY = camera.position.y;
currentGroundY = newGroundY;
grounded = true;
velocityY = 0f;
}
// else: it's a real wall or too high → ignore X movement
}
}
// --- Z axis ---
if (moveZ != 0f) {
float targetX = camera.position.x;
float targetZ = camera.position.z + moveZ;
boolean collides = world.collidesAt(
targetX,
curY,
targetZ,
playerRadius,
eyeHeight
);
if (!collides) {
camera.position.z = targetZ;
} else if (grounded && velocityY <= 0.01f) {
float newGroundY = world.getCapsuleGroundHeight(
targetX,
targetZ,
playerRadius,
0f
);
float step = newGroundY - currentGroundY;
if (step > 0f && step <= maxStepHeight) {
camera.position.z = targetZ;
camera.position.y = newGroundY + eyeHeight + stepSnapEps;
curY = camera.position.y;
currentGroundY = newGroundY;
grounded = true;
velocityY = 0f;
}
}
}
// =========================================================
// 3) RETRO QUANTIZATION
// =========================================================
if (retroQuantization) {
float q = positionQuantize;
camera.position.x = Math.round(camera.position.x * q) / q;
camera.position.y = Math.round(camera.position.y * q) / q;
camera.position.z = Math.round(camera.position.z * q) / q;
}
// =========================================================
// 4) VIEW BOBBING (purely visual)
// =========================================================
// horizontal movement magnitude (world units per frame)
if (settings.bobbingEnabled) {
float horizLen = (float)Math.sqrt(moveX * moveX + moveZ * moveZ);
if (grounded && horizLen > 0.0001f) {
// scale bob speed by how fast you're moving
float speedFactor = horizLen / (settings.moveSpeed * delta + 1e-6f);
bobTime += delta * bobSpeed * speedFactor;
bobOffsetY = (float)Math.sin(bobTime) * bobAmount;
} else {
// not moving or in the air → relax bobbing back to zero
bobTime = 0f;
// optional: smooth return to 0 instead of snapping
bobOffsetY += (0f - bobOffsetY) * 10f * delta;
}
camera.position.y += bobOffsetY;
}
}
private void updateCameraDirection() {
// build direction vector from yaw/pitch
float useYaw = yaw;
float usePitch = pitch;
if (retroQuantization) {
// snap yaw/pitch to fixed angular steps
useYaw = Math.round(useYaw / yawStepDegrees) * yawStepDegrees;
usePitch = Math.round(usePitch / pitchStepDegrees) * pitchStepDegrees;
}
float yawRad = (float) Math.toRadians(useYaw);
float pitchRad = (float) Math.toRadians(usePitch);
float x = (float) (Math.cos(pitchRad) * Math.sin(yawRad));
float y = (float) Math.sin(pitchRad);
float z = (float) (Math.cos(pitchRad) * Math.cos(yawRad));
camera.direction.set(x, y, z).nor();
}
private void ensureInvisibleCursor() {
if (invisibleCursor == null) {
Pixmap pm = new Pixmap(16, 16, Pixmap.Format.RGBA8888);
pm.setColor(0, 0, 0, 0);
pm.fill();
invisibleCursor = Gdx.graphics.newCursor(pm, 0, 0);
pm.dispose();
}
}
public void dispose() {
if (invisibleCursor != null) {
invisibleCursor.dispose();
invisibleCursor = null;
}
}
public float getYaw() { return yaw; }
public float getPitch() { return pitch; }
}

View File

@@ -0,0 +1,320 @@
package wtf.beatrice.retrorender.engine.render;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.PerspectiveCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.g3d.Environment;
import com.badlogic.gdx.graphics.g3d.ModelBatch;
import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute;
import com.badlogic.gdx.graphics.g3d.environment.DirectionalShadowLight;
import com.badlogic.gdx.graphics.g3d.utils.DepthShaderProvider;
import com.badlogic.gdx.graphics.glutils.FrameBuffer;
import com.badlogic.gdx.math.Vector3;
import wtf.beatrice.retrorender.Main;
import wtf.beatrice.retrorender.engine.config.GameSettings;
import wtf.beatrice.retrorender.engine.world.ModelLibrary;
import wtf.beatrice.retrorender.engine.ui.DebugHud;
import wtf.beatrice.retrorender.engine.ui.GameUi;
import wtf.beatrice.retrorender.engine.ui.UiMode;
import wtf.beatrice.retrorender.engine.world.World3D;
public class GameScreen implements Screen {
private final Main game;
private PerspectiveCamera camera;
private ModelBatch modelBatch;
private Environment environment;
private ColorAttribute ambientLight;
private DayNightCycle dayNightCycle;
private FpsCameraController cameraController;
private World3D world;
private DebugHud hud;
private boolean showHud = false;
// menus
private GameSettings settings;
private GameUi gameUi;
private UiMode uiMode = UiMode.GAMEPLAY;
// Shadow Mapping
private ModelBatch shadowBatch;
private DirectionalShadowLight shadowLight;
// Retro rendering
private FrameBuffer frameBuffer;
private TextureRegion frameRegion;
private SpriteBatch screenBatch;
// world
private ModelLibrary modelLibrary;
private static final int RETRO_WIDTH = 430;
private static final int RETRO_HEIGHT = 240;
public GameScreen(Main game) {
this.game = game;
}
private void initCamera() {
settings = new GameSettings();
camera = new PerspectiveCamera(settings.fov, RETRO_WIDTH, RETRO_HEIGHT);
cameraController = new FpsCameraController(camera, settings);
}
private void initEnvironment() {
environment = new Environment();
// keep a handle to ambient so we can animate it
ambientLight = new ColorAttribute(
ColorAttribute.AmbientLight,
0.4f, 0.4f, 0.5f, 1f
);
environment.set(ambientLight);
// shadow-casting directional light (our sun)
shadowLight = new DirectionalShadowLight(
1024, 1024, // shadow map resolution
60f, 60f, // viewport size
1f, 100f // near/far for the light camera
);
environment.add(shadowLight);
environment.shadowMap = shadowLight;
shadowBatch = new ModelBatch(new DepthShaderProvider());
// create the cycle controller
dayNightCycle = new DayNightCycle(shadowLight, ambientLight);
// optional: start at morning/noon/etc
dayNightCycle.setTimeOfDay(0.2f); // good sun
dayNightCycle.setAxialTilt(30f);
dayNightCycle.setDayLength(10f); // 5 minutes
}
private void initWorld() {
modelLibrary = new ModelLibrary();
world = new World3D(modelLibrary);
}
private void initMenus() {
hud = new DebugHud(dayNightCycle);
gameUi = new GameUi(settings);
}
private void initRetroBuffer() {
frameBuffer = new FrameBuffer(
com.badlogic.gdx.graphics.Pixmap.Format.RGBA8888,
RETRO_WIDTH,
RETRO_HEIGHT,
true
);
Texture fbTex = frameBuffer.getColorBufferTexture();
frameRegion = new TextureRegion(fbTex);
fbTex.setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest);
frameRegion.flip(false, true);
screenBatch = new SpriteBatch();
}
@Override
public void show() {
modelBatch = new ModelBatch();
initCamera();
initEnvironment();
initWorld();
initMenus();
initRetroBuffer();
cameraController.onShow(); // captures mouse with warmup
}
@Override
public void render(float delta) {
// TAB: toggle hud
if (Gdx.input.isKeyJustPressed(Input.Keys.TAB)) {
showHud = !showHud;
}
// ESC: toggle pause and mouse capture
if (Gdx.input.isKeyJustPressed(Input.Keys.ESCAPE)) {
UiMode newMode = gameUi.onEsc(uiMode);
boolean goingToGameplay = (newMode == UiMode.GAMEPLAY);
uiMode = newMode;
if (uiMode == UiMode.GAMEPLAY) {
cameraController.captureMouse();
} else {
cameraController.releaseMouse();
}
}
boolean gameplay = (uiMode == UiMode.GAMEPLAY);
if (gameplay) {
cameraController.update(delta, world);
world.update(delta);
}
// update day/night (you can choose to run even when paused if you prefer)
if (dayNightCycle != null && gameplay) {
dayNightCycle.update(delta);
}
// --- shadow pass ---
boolean doShadows = (dayNightCycle == null) || dayNightCycle.isSunAboveHorizon();
if (doShadows) {
if (dayNightCycle != null) {
Vector3 center = new Vector3(camera.position.x, 0f, camera.position.z);
Vector3 lightDir = dayNightCycle.getSunDirection();
shadowLight.begin(center, lightDir);
} else {
shadowLight.begin(new Vector3(0f, 0f, 0f), new Vector3(-0.7f, -1f, -0.3f));
}
shadowBatch.begin(shadowLight.getCamera());
world.render(shadowBatch, environment); // depth-only pass
shadowBatch.end();
shadowLight.end();
}
// --- render scene into low res framebuffer ---
frameBuffer.begin();
Gdx.gl.glViewport(0, 0, RETRO_WIDTH, RETRO_HEIGHT);
if (dayNightCycle != null) {
Color sky = dayNightCycle.getSkyColor();
Gdx.gl.glClearColor(sky.r, sky.g, sky.b, 1f);
} else {
Gdx.gl.glClearColor(0.5f, 0.6f, 1.0f, 1f);
}
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT | GL20.GL_DEPTH_BUFFER_BIT);
camera.update();
modelBatch.begin(camera);
world.render(modelBatch, environment);
modelBatch.end();
// HUD
if (showHud) {
hud.render(
RETRO_WIDTH,
RETRO_HEIGHT,
camera,
cameraController.getYaw(),
cameraController.getPitch()
);
}
// pause UI
if (uiMode != UiMode.GAMEPLAY) {
gameUi.render(RETRO_WIDTH, RETRO_HEIGHT, uiMode);
}
frameBuffer.end();
// -- scale framebuffer to screen, preserve aspect ratio --
int windowW = Gdx.graphics.getWidth();
int windowH = Gdx.graphics.getHeight();
int backW = Gdx.graphics.getBackBufferWidth();
int backH = Gdx.graphics.getBackBufferHeight();
RetroViewportHelper.RetroViewport vp =
RetroViewportHelper.computeViewport(windowW, windowH, RETRO_WIDTH, RETRO_HEIGHT);
// click → UI
if (uiMode != UiMode.GAMEPLAY && Gdx.input.justTouched()) {
int mouseX = Gdx.input.getX();
int mouseY = Gdx.input.getY();
RetroViewportHelper.RetroClick rc =
RetroViewportHelper.toRetroCoords(
vp,
RETRO_WIDTH, RETRO_HEIGHT,
mouseX, mouseY,
windowH
);
if (rc.inside) {
GameUi.UiResult result = gameUi.handleClick(rc.x, rc.y, uiMode);
switch (result) {
case RESUME:
uiMode = UiMode.GAMEPLAY;
cameraController.captureMouse();
break;
case OPEN_SETTINGS:
uiMode = UiMode.SETTINGS;
// mouse already released when we entered PAUSE, so nothing to do
break;
case CLOSE_SETTINGS:
uiMode = UiMode.PAUSE;
// stay in pause, mouse remains free
break;
case QUIT:
Gdx.app.exit();
break;
case NONE:
default:
break;
}
}
}
// viewport in backbuffer coordinates
// final draw using vp.offsetX / vp.offsetY / vp.drawW / vp.drawH
Gdx.gl.glViewport(0, 0, backW, backH);
screenBatch.getProjectionMatrix().setToOrtho2D(0, 0, windowW, windowH);
screenBatch.begin();
screenBatch.draw(frameRegion, vp.offsetX, vp.offsetY, vp.drawW, vp.drawH);
screenBatch.end();
}
@Override
public void resize(int width, int height) {
camera.viewportWidth = RETRO_WIDTH;
camera.viewportHeight = RETRO_HEIGHT;
camera.update();
cameraController.resize(width, height);
}
@Override public void pause() { }
@Override public void resume() { }
@Override
public void hide() {
cameraController.onHide();
}
@Override
public void dispose() {
cameraController.dispose();
world.dispose();
modelBatch.dispose();
hud.dispose();
shadowBatch.dispose();
shadowLight.dispose();
if (gameUi != null) gameUi.dispose();
if (frameBuffer != null) frameBuffer.dispose();
if (screenBatch != null) screenBatch.dispose();
}
}

View File

@@ -0,0 +1,60 @@
package wtf.beatrice.retrorender.engine.render;
public class RetroViewportHelper {
public static class RetroViewport {
public int offsetX, offsetY;
public int drawW, drawH;
public float scale;
}
public static class RetroClick {
public boolean inside;
public float x, y; // retro coords
}
public static RetroViewport computeViewport(
int windowW, int windowH,
int retroW, int retroH) {
RetroViewport vp = new RetroViewport();
float scale = Math.min(
windowW / (float) retroW,
windowH / (float) retroH
);
vp.scale = scale;
vp.drawW = Math.round(retroW * scale);
vp.drawH = Math.round(retroH * scale);
vp.offsetX = (windowW - vp.drawW) / 2;
vp.offsetY = (windowH - vp.drawH) / 2;
return vp;
}
public static RetroClick toRetroCoords(
RetroViewport vp,
int retroW, int retroH,
int mouseX, int mouseYWindow,
int windowHeight) {
RetroClick rc = new RetroClick();
// libGDX gives mouse from top-left; you already invert Y like this:
int yBottomOrigin = windowHeight - mouseYWindow;
if (mouseX < vp.offsetX || mouseX > vp.offsetX + vp.drawW ||
yBottomOrigin < vp.offsetY || yBottomOrigin > vp.offsetY + vp.drawH) {
rc.inside = false;
return rc;
}
float relX = (mouseX - vp.offsetX) / (float) vp.drawW;
float relY = (yBottomOrigin - vp.offsetY) / (float) vp.drawH;
rc.inside = true;
rc.x = relX * retroW;
rc.y = relY * retroH;
return rc;
}
}

View File

@@ -1,19 +1,25 @@
package wtf.beatrice.retrorender.engine; package wtf.beatrice.retrorender.engine.ui;
import com.badlogic.gdx.graphics.Color; import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.PerspectiveCamera; import com.badlogic.gdx.graphics.PerspectiveCamera;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture; import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.BitmapFont; import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch; import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.Gdx; import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator; import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator;
import wtf.beatrice.retrorender.engine.render.DayNightCycle;
public class DebugHud { public class DebugHud {
private final SpriteBatch batch; private final SpriteBatch batch;
private final BitmapFont font; private final BitmapFont font;
private DayNightCycle dayNightCycle;
private final Texture pixel;
public DebugHud(DayNightCycle dayNightCycle) {
this.dayNightCycle = dayNightCycle;
public DebugHud() {
batch = new SpriteBatch(); batch = new SpriteBatch();
FreeTypeFontGenerator generator = FreeTypeFontGenerator generator =
@@ -38,6 +44,13 @@ public class DebugHud {
font = generator.generateFont(param); font = generator.generateFont(param);
generator.dispose(); generator.dispose();
// 1x1 pixel
Pixmap pm = new Pixmap(1, 1, Pixmap.Format.RGBA8888);
pm.setColor(1f, 1f, 1f, 1f);
pm.fill();
pixel = new Texture(pm);
pm.dispose();
} }
// render HUD in virtual resolution // render HUD in virtual resolution
@@ -49,24 +62,54 @@ public class DebugHud {
batch.begin(); batch.begin();
String fpsText = Gdx.graphics.getFramesPerSecond() + " FPS";
font.draw(batch, fpsText, 5f, height - 5f);
if (camera != null) { if (camera != null) {
float x = camera.position.x; float x = camera.position.x;
float y = camera.position.y; float y = camera.position.y;
float z = camera.position.z; float z = camera.position.z;
String posText = String.format("X: %.2f Y: %.2f Z: %.2f", x, y, z); float panelW = 275f;
float panelH = 45f;
float panelX = 2f;
float panelY = height - panelH - 2f;
// background
batch.setColor(0f, 0f, 0f, 0.10f);
batch.draw(pixel, panelX, panelY, panelW, panelH);
String fpsText = Gdx.graphics.getFramesPerSecond() + " FPS";
font.draw(batch, fpsText, 5f, height - 5f);
String posText = String.format("X: %.3f Y: %.3f Z: %.3f", x, y, z);
font.draw(batch, posText, 5f, height - 15f); font.draw(batch, posText, 5f, height - 15f);
String angText = String.format("Yaw: %.1f Pitch: %.1f", yaw, pitch); String angText = String.format("Yaw: %.2f Pitch: %.2f", yaw, pitch);
font.draw(batch, angText, 5f, height - 25f); font.draw(batch, angText, 5f, height - 25f);
String dayText = String.format("Time of day: %.3f [%s] Sun height: %.3f", dayNightCycle.getTimeOfDay(), formatTimeOfDay(dayNightCycle.getTimeOfDay()), dayNightCycle.getSunAltitude());
font.draw(batch, dayText, 5f, height - 35f);
} }
batch.end(); batch.end();
} }
private String formatTimeOfDay(float timeOfDay) {
// keep it in [0,1)
float t = timeOfDay - (float)Math.floor(timeOfDay);
float hoursF = t * 24f;
int hours = (int)Math.floor(hoursF);
float minutesF = (hoursF - hours) * 60f;
int minutes = (int)Math.floor(minutesF);
// clamp just in case of float weirdness
if (hours < 0) hours = 0;
if (hours > 23) hours = 23;
if (minutes < 0) minutes = 0;
if (minutes > 59) minutes = 59;
return String.format("%02d:%02d", hours, minutes);
}
public void dispose() { public void dispose() {
batch.dispose(); batch.dispose();
font.dispose(); font.dispose();

View File

@@ -0,0 +1,87 @@
package wtf.beatrice.retrorender.engine.ui;
import wtf.beatrice.retrorender.engine.config.GameSettings;
public class GameUi {
public enum UiResult {
NONE,
RESUME,
OPEN_SETTINGS,
CLOSE_SETTINGS,
QUIT
}
private final PauseMenu pauseMenu;
private final SettingsMenu settingsMenu;
private final GameSettings settings;
public GameUi(GameSettings settings) {
this.settings = settings;
this.pauseMenu = new PauseMenu();
this.settingsMenu = new SettingsMenu(settings);
}
public UiMode onEsc(UiMode current) {
switch (current) {
case GAMEPLAY:
return UiMode.PAUSE;
case PAUSE:
case SETTINGS:
return UiMode.GAMEPLAY;
default:
return UiMode.GAMEPLAY;
}
}
public void render(int retroW, int retroH, UiMode mode) {
switch (mode) {
case PAUSE:
pauseMenu.render(retroW, retroH);
break;
case SETTINGS:
settingsMenu.render(retroW, retroH);
break;
case GAMEPLAY:
default:
break;
}
}
public UiResult handleClick(float retroX, float retroY, UiMode mode) {
if (mode == UiMode.SETTINGS) {
boolean close = settingsMenu.handleClick(retroX, retroY);
// always sync FOV to settings
settings.fov = settingsMenu.getFov();
settings.bobbingEnabled = settingsMenu.isBobbingEnabled();
if (close) {
// Settings “Close” button clicked -> go back to PAUSE
return UiResult.CLOSE_SETTINGS;
}
return UiResult.NONE;
}
if (mode == UiMode.PAUSE) {
PauseMenu.MenuAction action = pauseMenu.getActionAt(retroX, retroY);
return switch (action)
{
case RESUME -> UiResult.RESUME;
case QUIT -> UiResult.QUIT;
case SETTINGS -> UiResult.OPEN_SETTINGS;
default -> UiResult.NONE;
};
}
return UiResult.NONE;
}
public void dispose() {
pauseMenu.dispose();
settingsMenu.dispose();
}
public SettingsMenu getSettingsMenu() {
return settingsMenu;
}
}

View File

@@ -0,0 +1,165 @@
package wtf.beatrice.retrorender.engine.ui;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.GlyphLayout;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator;
import java.util.ArrayList;
import java.util.List;
public class PauseMenu {
private final SpriteBatch batch;
private final BitmapFont font;
private final Texture pixel;
// simple action enum for the menu
public enum MenuAction {
NONE,
RESUME,
SETTINGS,
QUIT
}
// simple button model
private static class Button {
String label;
MenuAction action;
float x, y, w, h;
Button(String label, MenuAction action) {
this.label = label;
this.action = action;
}
}
private final List<Button> buttons = new ArrayList<>();
public PauseMenu() {
batch = new SpriteBatch();
// Same style as DebugHud
FreeTypeFontGenerator generator =
new FreeTypeFontGenerator(Gdx.files.internal("fonts/red-hat-mono.ttf"));
FreeTypeFontGenerator.FreeTypeFontParameter param =
new FreeTypeFontGenerator.FreeTypeFontParameter();
param.size = 12;
param.color = Color.WHITE;
param.mono = true;
param.hinting = FreeTypeFontGenerator.Hinting.None;
param.borderWidth = 0f;
param.borderColor = Color.WHITE;
param.shadowOffsetX = 0;
param.shadowOffsetY = 0;
param.minFilter = Texture.TextureFilter.Nearest;
param.magFilter = Texture.TextureFilter.Nearest;
font = generator.generateFont(param);
generator.dispose();
// create buttons (ordered left → right)
buttons.add(new Button(" Resume ", MenuAction.RESUME));
buttons.add(new Button(" Settings ", MenuAction.SETTINGS));
buttons.add(new Button(" Quit ", MenuAction.QUIT));
// 1x1 pixel texture (white, we tint it)
Pixmap pm = new Pixmap(1, 1, Pixmap.Format.RGBA8888);
pm.setColor(1f, 1f, 1f, 1f);
pm.fill();
pixel = new Texture(pm);
pm.dispose();
}
public void render(int width, int height) {
batch.getProjectionMatrix().setToOrtho2D(0, 0, width, height);
batch.begin();
// --- black bar at bottom ---
float barH = 80f; // in retro pixels
float barY = 0f;
batch.setColor(0f, 0f, 0f, 0.9f);
batch.draw(pixel, 0f, barY, width, barH);
// --- buttons ---
float spacing = 10f; // horizontal space between buttons
// measure each button label
GlyphLayout layout = new GlyphLayout();
float totalWidth = 0f;
float maxButtonH = 0f;
for (Button b : buttons) {
layout.setText(font, b.label);
float bw = layout.width + 16f; // padding
float bh = layout.height + 12f;
b.w = bw;
b.h = bh;
totalWidth += bw;
if (bh > maxButtonH) maxButtonH = bh;
}
// add spacing between buttons
if (!buttons.isEmpty()) {
totalWidth += spacing * (buttons.size() - 1);
}
float startX = (width - totalWidth) / 2f;
float centerY = barY + (barH - maxButtonH) / 2f;
// draw each button
float currentX = startX;
for (Button b : buttons) {
b.x = currentX;
b.y = centerY;
// button background
batch.setColor(0.1f, 0.1f, 0.1f, 1f);
batch.draw(pixel, b.x, b.y, b.w, b.h);
// simple 1px white border
batch.setColor(1f, 1f, 1f, 1f);
float border = 1f;
// top
batch.draw(pixel, b.x, b.y + b.h - border, b.w, border);
// bottom
batch.draw(pixel, b.x, b.y, b.w, border);
// left
batch.draw(pixel, b.x, b.y, border, b.h);
// right
batch.draw(pixel, b.x + b.w - border, b.y, border, b.h);
// text
layout.setText(font, b.label);
float textX = b.x + (b.w - layout.width) / 2f;
float textY = b.y + b.h - (b.h - layout.height) / 2f - 2f;
font.draw(batch, layout, textX, textY);
currentX += b.w + spacing;
}
batch.end();
}
public MenuAction getActionAt(float x, float y) {
for (Button b : buttons) {
if (x >= b.x && x <= b.x + b.w &&
y >= b.y && y <= b.y + b.h) {
return b.action;
}
}
return MenuAction.NONE;
}
public void dispose() {
batch.dispose();
font.dispose();
pixel.dispose();
}
}

View File

@@ -0,0 +1,233 @@
package wtf.beatrice.retrorender.engine.ui;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.GlyphLayout;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator;
import com.badlogic.gdx.math.MathUtils;
import wtf.beatrice.retrorender.engine.config.GameSettings;
public class SettingsMenu {
private final SpriteBatch batch;
private final BitmapFont font;
private final BitmapFont boldFont;
private final Texture pixel;
private final GameSettings settings;
private boolean bobbingEnabled = true;
// button / hit areas
private float fovDecX, fovDecY, fovDecW, fovDecH;
private float fovIncX, fovIncY, fovIncW, fovIncH;
private float closeX, closeY, closeW, closeH;
public SettingsMenu(GameSettings settings) {
this.settings = settings;
batch = new SpriteBatch();
FreeTypeFontGenerator generator =
new FreeTypeFontGenerator(Gdx.files.internal("fonts/red-hat-mono.ttf"));
FreeTypeFontGenerator.FreeTypeFontParameter param =
new FreeTypeFontGenerator.FreeTypeFontParameter();
param.size = 12;
param.color = Color.WHITE;
param.mono = true;
param.hinting = FreeTypeFontGenerator.Hinting.None;
param.borderWidth = 0f;
param.borderColor = Color.WHITE;
param.shadowOffsetX = 0;
param.shadowOffsetY = 0;
param.minFilter = Texture.TextureFilter.Nearest;
param.magFilter = Texture.TextureFilter.Nearest;
font = generator.generateFont(param);
param.borderWidth = 0.2f;
boldFont = generator.generateFont(param);
generator.dispose();
// 1x1 pixel
Pixmap pm = new Pixmap(1, 1, Pixmap.Format.RGBA8888);
pm.setColor(1f, 1f, 1f, 1f);
pm.fill();
pixel = new Texture(pm);
pm.dispose();
}
public void setFov(float fov) {
settings.fov = MathUtils.clamp(fov, settings.minFov, settings.maxFov);
}
public float getFov() {
return settings.fov;
}
public boolean isBobbingEnabled()
{
return bobbingEnabled;
}
public void setBobbingEnabled(boolean enabled){
bobbingEnabled = enabled;
}
/**
* @param width RETRO_WIDTH
* @param height RETRO_HEIGHT
*/
public void render(int width, int height) {
batch.getProjectionMatrix().setToOrtho2D(0, 0, width, height);
batch.begin();
GlyphLayout layout = new GlyphLayout();
// --- central panel ---
float panelW = 300f;
float panelH = 220f;
float panelX = (width - panelW) / 2f;
float panelY = (height - panelH) / 2f;
// background
batch.setColor(0f, 0f, 0f, 0.9f);
batch.draw(pixel, panelX, panelY, panelW, panelH);
// border
batch.setColor(1f, 1f, 1f, 1f);
float border = 1f;
// --- title ---
String title = "Settings";
layout.setText(boldFont, title);
float titleX = panelX + (panelW - layout.width) / 2f;
float titleY = panelY + panelH - 10f;
boldFont.draw(batch, layout, titleX, titleY);
// small separator line under title
batch.draw(pixel, panelX + 8f, titleY - 14f, panelW - 16f, 1f);
// --- FOV row ---
float rowY = panelY + panelH - 50f;
float rowH = 18f;
// label
String fovLabel = "Field of View";
layout.setText(font, fovLabel);
float labelX = panelX + 12f;
float labelY = rowY + rowH - 4f;
font.draw(batch, layout, labelX, labelY);
// buttons + value area
fovDecW = 20f;
fovDecH = rowH;
fovDecX = panelX + 180f;
fovDecY = rowY;
fovIncW = 20f;
fovIncH = rowH;
fovIncX = panelX + panelW - 32f;
fovIncY = rowY;
// draw dec button "<"
drawButtonWithLabel("<", fovDecX, fovDecY, fovDecW, fovDecH);
// draw inc button ">"
drawButtonWithLabel(">", fovIncX, fovIncY, fovIncW, fovIncH);
// numeric value centered between dec/inc
String fovText = String.format("%3.0f°", settings.fov);
layout.setText(font, fovText);
float valueCenter = (fovDecX + fovDecW + fovIncX) / 2f;
float valueX = valueCenter - layout.width / 2f;
float valueY = rowY + rowH - 4f;
font.draw(batch, layout, valueX, valueY);
// --- Close button at bottom ---
String closeLabel = " Close ";
layout.setText(font, closeLabel);
closeW = layout.width + 64f;
closeH = layout.height + 12f;
closeX = panelX + (panelW - closeW) / 2f;
closeY = panelY + 16f;
// button bg + border
batch.setColor(0.1f, 0.1f, 0.1f, 1f);
batch.draw(pixel, closeX, closeY, closeW, closeH);
batch.setColor(1f, 1f, 1f, 1f);
// border
batch.draw(pixel, closeX, closeY + closeH - border, closeW, border);
batch.draw(pixel, closeX, closeY, closeW, border);
batch.draw(pixel, closeX, closeY, border, closeH);
batch.draw(pixel, closeX + closeW - border, closeY, border, closeH);
float closeTextX = closeX + (closeW - layout.width) / 2f;
float closeTextY = closeY + closeH - (closeH - layout.height) / 2f;
font.draw(batch, closeLabel, closeTextX, closeTextY);
batch.end();
}
private void drawButtonWithLabel(String text, float x, float y, float w, float h) {
// background
batch.setColor(0.1f, 0.1f, 0.1f, 1f);
batch.draw(pixel, x, y, w, h);
// border
batch.setColor(1f, 1f, 1f, 1f);
float border = 1f;
batch.draw(pixel, x, y + h - border, w, border);
batch.draw(pixel, x, y, w, border);
batch.draw(pixel, x, y, border, h);
batch.draw(pixel, x + w - border, y, border, h);
GlyphLayout layout = new GlyphLayout(font, text);
float tx = x + (w - layout.width) / 2f;
float ty = y + h - (h - layout.height) / 2f;
font.draw(batch, layout, tx, ty);
}
/**
* Handle a click in RETRO coords.
*
* @return true if the menu wants to close (Close button clicked)
*/
public boolean handleClick(float x, float y) {
// FOV -
if (x >= fovDecX && x <= fovDecX + fovDecW &&
y >= fovDecY && y <= fovDecY + fovDecH) {
settings.fov = MathUtils.clamp(settings.fov - settings.fovStep, settings.minFov, settings.maxFov);
return false;
}
// FOV +
if (x >= fovIncX && x <= fovIncX + fovIncW &&
y >= fovIncY && y <= fovIncY + fovIncH) {
settings.fov = MathUtils.clamp(settings.fov + settings.fovStep, settings.minFov, settings.maxFov);
return false;
}
// Close
if (x >= closeX && x <= closeX + closeW &&
y >= closeY && y <= closeY + closeH) {
return true;
}
return false;
}
public void dispose() {
batch.dispose();
font.dispose();
pixel.dispose();
}
}

View File

@@ -0,0 +1,7 @@
package wtf.beatrice.retrorender.engine.ui;
public enum UiMode {
GAMEPLAY,
PAUSE, // only bottom bar
SETTINGS // pause bar + center settings
}

View File

@@ -0,0 +1,29 @@
package wtf.beatrice.retrorender.engine.world;
import com.badlogic.gdx.math.Vector3;
public class Collider {
public enum Type {
NONE,
BOX // axis-aligned box
// later: SPHERE, CAPSULE, MESH, etc.
}
public Type type = Type.NONE;
// Local-space center + extents relative to ModelInstance transform
public final Vector3 center = new Vector3(0f, 0f, 0f);
public final Vector3 halfExtents = new Vector3(1f, 1f, 1f);
public static Collider none() {
return new Collider();
}
public static Collider box(float hx, float hy, float hz) {
Collider c = new Collider();
c.type = Type.BOX;
c.halfExtents.set(hx, hy, hz);
return c;
}
}

View File

@@ -0,0 +1,129 @@
package wtf.beatrice.retrorender.engine.world;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.VertexAttributes;
import com.badlogic.gdx.graphics.g3d.*;
import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute;
import com.badlogic.gdx.graphics.g3d.attributes.TextureAttribute;
import com.badlogic.gdx.graphics.g3d.utils.MeshPartBuilder;
import com.badlogic.gdx.graphics.g3d.utils.ModelBuilder;
public class ModelLibrary {
private final ModelBuilder builder = new ModelBuilder();
public final Texture groundTexture;
public final Texture pathTexture;
public final Texture houseTexture; // <-- new
public final Model groundModel;
public final Model unitCubeModel;
// town-related primitives
public final Model houseBlockModel;
public final Model pathTileModel;
public ModelLibrary() {
// --- ground texture + model ---
groundTexture = new Texture(Gdx.files.internal("textures/rocky_grass_2.png"));
groundTexture.setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest);
groundTexture.setWrap(Texture.TextureWrap.Repeat, Texture.TextureWrap.Repeat);
float halfSize = 32f;
float tileScale = 16f;
builder.begin();
Material groundMat = new Material(
ColorAttribute.createDiffuse(1f, 1f, 1f, 1f),
TextureAttribute.createDiffuse(groundTexture)
);
MeshPartBuilder mpb = builder.part(
"ground",
GL20.GL_TRIANGLES,
VertexAttributes.Usage.Position
| VertexAttributes.Usage.Normal
| VertexAttributes.Usage.TextureCoordinates,
groundMat
);
mpb.setUVRange(0f, 0f, tileScale, tileScale);
mpb.rect(
-halfSize, 0f, halfSize,
halfSize, 0f, halfSize,
halfSize, 0f, -halfSize,
-halfSize, 0f, -halfSize,
0f, 1f, 0f
);
groundModel = builder.end();
// generic cube (still untextured)
unitCubeModel = builder.createBox(
1f, 1f, 1f,
new Material(ColorAttribute.createDiffuse(1f, 1f, 1f, 1f)),
VertexAttributes.Usage.Position | VertexAttributes.Usage.Normal
);
// --- house texture + model ---
// assumes textures/texture_09.png exists
houseTexture = new Texture(Gdx.files.internal("textures/texture_03.png"));
houseTexture.setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest);
houseTexture.setWrap(Texture.TextureWrap.Repeat, Texture.TextureWrap.Repeat);
houseBlockModel = builder.createBox(
4f, 3f, 4f,
new Material(
TextureAttribute.createDiffuse(houseTexture)
// optionally tint:
// ColorAttribute.createDiffuse(0.9f, 0.8f, 0.7f, 1f)
),
VertexAttributes.Usage.Position
| VertexAttributes.Usage.Normal
| VertexAttributes.Usage.TextureCoordinates // <-- important
);
// --- path tile texture + model ---
pathTexture = new Texture(Gdx.files.internal("textures/paving_2.png"));
pathTexture.setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest);
pathTexture.setWrap(Texture.TextureWrap.Repeat, Texture.TextureWrap.Repeat);
builder.begin();
Material pathMat = new Material(
TextureAttribute.createDiffuse(pathTexture)
);
MeshPartBuilder pathMpb = builder.part(
"pathTile",
GL20.GL_TRIANGLES,
VertexAttributes.Usage.Position
| VertexAttributes.Usage.Normal
| VertexAttributes.Usage.TextureCoordinates,
pathMat
);
float tHalf = 1f; // 2x2 tile
float y = 0.01f;
pathMpb.setUVRange(0f, 0f, 0.5f, 0.5f);
pathMpb.rect(
-tHalf, y, tHalf,
tHalf, y, tHalf,
tHalf, y, -tHalf,
-tHalf, y, -tHalf,
0f, 1f, 0f
);
pathTileModel = builder.end();
}
public void dispose() {
groundModel.dispose();
unitCubeModel.dispose();
houseBlockModel.dispose();
pathTileModel.dispose();
groundTexture.dispose();
pathTexture.dispose();
houseTexture.dispose(); // <-- new
}
}

View File

@@ -0,0 +1,53 @@
package wtf.beatrice.retrorender.engine.world;
import com.badlogic.gdx.graphics.g3d.ModelInstance;
public class TownFactory {
private final ModelLibrary models;
public TownFactory(ModelLibrary models) {
this.models = models;
}
public WorldObject createHouse(String id, float x, float z) {
ModelInstance inst = new ModelInstance(models.houseBlockModel);
// houseBlockModel is 4x3x4 → center at y=1.5
inst.transform.setToTranslation(x, 1.5f, z);
Collider col = Collider.box(2f, 1.5f, 2f);
WorldObject obj = new WorldObject(id, inst, col);
obj.staticObject = true;
obj.castsShadow = true;
obj.walkableSurface = false; // you don't walk on roofs
obj.collidable = true; // blocks movement
return obj;
}
public WorldObject createCrate(String id, float x, float z) {
ModelInstance inst = new ModelInstance(models.unitCubeModel);
// unit cube is 1x1x1 → center at y=0.5
inst.transform.setToTranslation(x, 0.5f, z);
Collider col = Collider.box(0.5f, 0.5f, 0.5f);
WorldObject obj = new WorldObject(id, inst, col);
obj.staticObject = true;
obj.castsShadow = true;
obj.walkableSurface = true; // you can stand on it
obj.collidable = true; // also blocks sides
return obj;
}
public WorldObject createPathTile(String id, float x, float z) {
ModelInstance inst = new ModelInstance(models.pathTileModel);
inst.transform.setToTranslation(x, 0f, z);
WorldObject obj = new WorldObject(id, inst, Collider.none());
obj.staticObject = true;
obj.walkableSurface = false; // ground plane already gives height 0
obj.collidable = false;
return obj;
}
}

View File

@@ -0,0 +1,245 @@
package wtf.beatrice.retrorender.engine.world;
import com.badlogic.gdx.graphics.g3d.Environment;
import com.badlogic.gdx.graphics.g3d.ModelBatch;
import com.badlogic.gdx.graphics.g3d.ModelInstance;
import com.badlogic.gdx.math.Matrix4;
import com.badlogic.gdx.math.Vector3;
import java.util.ArrayList;
import java.util.List;
public class World3D {
private final ModelLibrary models;
private final WorldObject ground;
private final List<WorldObject> objects = new ArrayList<>();
private final TownFactory townFactory;
private final Vector3 tmpWorld = new Vector3();
private final Vector3 tmpFeet = new Vector3();
private final Vector3 tmpHead = new Vector3();
private final Vector3 tmpLocalFeet = new Vector3();
private final Vector3 tmpLocalHead = new Vector3();
private final Matrix4 tmpMat = new Matrix4();
private final Matrix4 tmpInv = new Matrix4();
private final Vector3 tmp = new Vector3();
public World3D(ModelLibrary models) {
this.models = models;
this.townFactory = new TownFactory(models);
// --- ground ---
ground = new WorldObject(
"ground",
new ModelInstance(models.groundModel),
Collider.none()
);
ground.staticObject = true;
// --- build a tiny town layout instead of random cubes ---
buildTestTown();
}
private void addObject(WorldObject obj) {
objects.add(obj);
}
private void buildTestTown() {
float tileSize = 2f; // world units per “map tile”
// Simple 7x7 layout:
// H = house, P = path, . = empty
String[] layout = {
".............",
"..H.C.....H..",
"..P.......P..",
"..P.......P..",
"..P.......P..",
"..P.......P..",
"..P.......P..",
"..P.......P..",
"..H.......H..",
"............."
};
int rows = layout.length;
int cols = layout[0].length();
float offsetX = -(cols - 1) * tileSize * 0.5f;
float offsetZ = -(rows - 1) * tileSize * 0.5f;
for (int row = 0; row < rows; row++) {
String line = layout[row];
for (int col = 0; col < cols; col++) {
char c = line.charAt(col);
float x = offsetX + col * tileSize;
float z = offsetZ + row * tileSize;
switch (c) {
case 'H':
addObject(townFactory.createHouse("house_" + row + "_" + col, x, z));
break;
case 'P':
addObject(townFactory.createPathTile("path_" + row + "_" + col, x, z));
break;
case 'C':
addObject(townFactory.createCrate("crate_" + row + "_" + col, x, z));
default:
break;
}
}
}
// maybe put a crate in the middle
}
public void update(float delta) {
// here you can update objects that animate / move
for (WorldObject obj : objects) {
obj.update(delta);
}
}
public void render(ModelBatch batch, Environment environment) {
// draw ground
batch.render(ground.instance, environment);
// draw objects
for (WorldObject obj : objects) {
batch.render(obj.instance, environment);
}
}
/**
* Returns the ground height (y) for a capsule with given radius whose center
* is at (x, z) in world space. Starts from baseHeight (e.g. 0) and checks
* walkable BOX colliders using a circle-vs-AABB test in XZ.
*/
public float getCapsuleGroundHeight(float x, float z, float radius, float baseHeight) {
float maxY = baseHeight; // plane at y = baseHeight
for (WorldObject obj : objects) {
if (!obj.walkableSurface) continue;
if (obj.collider.type != Collider.Type.BOX) continue;
Collider col = obj.collider;
// world → local
tmpInv.set(obj.instance.transform);
tmpInv.inv();
// capsule center projected to XZ in local space
tmpWorld.set(x, 0f, z);
tmpWorld.mul(tmpInv);
float cx = tmpWorld.x;
float cz = tmpWorld.z;
float boxMinX = col.center.x - col.halfExtents.x;
float boxMaxX = col.center.x + col.halfExtents.x;
float boxMinZ = col.center.z - col.halfExtents.z;
float boxMaxZ = col.center.z + col.halfExtents.z;
// circle (capsule footprint) vs AABB in local XZ
float closestX = Math.max(boxMinX, Math.min(cx, boxMaxX));
float closestZ = Math.max(boxMinZ, Math.min(cz, boxMaxZ));
float dx = cx - closestX;
float dz = cz - closestZ;
if (dx * dx + dz * dz > radius * radius) {
// no overlap in XZ with this walkable surface
continue;
}
// Local top Y of the box
float localTopY = col.center.y + col.halfExtents.y;
// Convert that top point back to world space
tmpWorld.set(col.center.x, localTopY, col.center.z);
tmpWorld.mul(obj.instance.transform);
if (tmpWorld.y > maxY) {
maxY = tmpWorld.y;
}
}
return maxY;
}
/**
* Backwards-compatible point-sample version (radius = 0).
*/
public float getGroundHeightAt(float x, float z, float baseHeight) {
return getCapsuleGroundHeight(x, z, 0f, baseHeight);
}
/**
* Simple horizontal collision check between a player and BOX colliders.
* Player is approximated as a capsule: radius in XZ, height = eyeHeight.
*/
public boolean collidesAt(float x, float y, float z, float radius, float eyeHeight) {
float feetY = y - eyeHeight;
float headY = y;
final float EPS_TOP = 0.05f;
final float EPS_BOTTOM = 0.01f;
for (WorldObject obj : objects) {
if (!obj.collidable) continue;
if (obj.collider.type != Collider.Type.BOX) continue;
Collider col = obj.collider;
tmpInv.set(obj.instance.transform);
tmpInv.inv();
tmpFeet.set(x, feetY, z).mul(tmpInv);
tmpHead.set(x, headY, z).mul(tmpInv);
float boxMinY = col.center.y - col.halfExtents.y;
float boxMaxY = col.center.y + col.halfExtents.y;
if (tmpFeet.y >= boxMaxY - EPS_TOP) {
// on / just above top → let groundHeight handle it
continue;
}
if (tmpHead.y <= boxMinY + EPS_BOTTOM || tmpFeet.y >= boxMaxY) {
continue;
}
float px = 0.5f * (tmpFeet.x + tmpHead.x);
float pz = 0.5f * (tmpFeet.z + tmpHead.z);
float minX = col.center.x - col.halfExtents.x;
float maxX = col.center.x + col.halfExtents.x;
float minZ = col.center.z - col.halfExtents.z;
float maxZ = col.center.z + col.halfExtents.z;
float closestX = Math.max(minX, Math.min(px, maxX));
float closestZ = Math.max(minZ, Math.min(pz, maxZ));
float dx = px - closestX;
float dz = pz - closestZ;
if (dx * dx + dz * dz <= radius * radius) {
return true;
}
}
return false;
}
public void dispose() {
models.dispose(); // or let GameScreen own/dispose the library
}
}

View File

@@ -0,0 +1,40 @@
package wtf.beatrice.retrorender.engine.world;
import com.badlogic.gdx.graphics.g3d.ModelInstance;
import com.badlogic.gdx.math.Vector3;
public class WorldObject {
public final String id;
public final ModelInstance instance;
public final Collider collider;
public boolean staticObject = true;
public boolean castsShadow = true;
// NEW: gameplay flags
public boolean walkableSurface = false; // contributes to getGroundHeightAt
public boolean collidable = true; // used by collidesAt
// Cached translation for faster queries
private final Vector3 tmpPos = new Vector3();
public WorldObject(String id, ModelInstance instance, Collider collider) {
this.id = id;
this.instance = instance;
this.collider = collider;
}
public void update(float delta) {
// default: do nothing
// For rotating decoration, set collidable=false + walkableSurface=false
// and rotate instance.transform here if you want.
}
public Vector3 getPosition(Vector3 out) {
instance.transform.getTranslation(out);
return out;
}
public Vector3 getPosition() {
return getPosition(tmpPos);
}
}

View File

@@ -28,7 +28,7 @@ public class Lwjgl3Launcher {
//// useful for testing performance, but can also be very stressful to some hardware. //// useful for testing performance, but can also be very stressful to some hardware.
//// You may also need to configure GPU drivers to fully disable Vsync; this can cause screen tearing. //// You may also need to configure GPU drivers to fully disable Vsync; this can cause screen tearing.
configuration.setWindowedMode(640, 480); configuration.setWindowedMode(860, 480);
//// You can change these files; they are in lwjgl3/src/main/resources/ . //// You can change these files; they are in lwjgl3/src/main/resources/ .
//// They can also be loaded from the root of assets/ . //// They can also be loaded from the root of assets/ .