Compare commits
15 Commits
008bbff99c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e01fc8bd4 | |||
| 9e86abe60b | |||
| dbbcd7f926 | |||
| a951b3bcb2 | |||
| eb1542e161 | |||
| 94fefa6d2c | |||
| 0c726ed416 | |||
| 7a599ab1e5 | |||
| 398f1fa26f | |||
| db8d78043f | |||
| edc983091b | |||
| 6868f53e38 | |||
| caa59cd72f | |||
| a31c018b92 | |||
| 6f28e01e5c |
BIN
assets/textures/dirt.png
Normal file
BIN
assets/textures/dirt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
BIN
assets/textures/grass.png
Normal file
BIN
assets/textures/grass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
assets/textures/moss.png
Normal file
BIN
assets/textures/moss.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
BIN
assets/textures/paving_2.png
Normal file
BIN
assets/textures/paving_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
BIN
assets/textures/rocky_grass.png
Normal file
BIN
assets/textures/rocky_grass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
assets/textures/rocky_grass_2.png
Normal file
BIN
assets/textures/rocky_grass_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
package wtf.beatrice.retrorender;
|
||||
|
||||
import com.badlogic.gdx.ApplicationAdapter;
|
||||
import com.badlogic.gdx.Game;
|
||||
import com.badlogic.gdx.Gdx;
|
||||
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;
|
||||
import wtf.beatrice.retrorender.engine.render.GameScreen;
|
||||
|
||||
/** {@link com.badlogic.gdx.ApplicationListener} implementation shared by all platforms. */
|
||||
public class Main extends Game
|
||||
|
||||
@@ -1,244 +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 float eyeHeight = 1.8f;
|
||||
private float velocityY = 0f;
|
||||
private float gravity = -50f;
|
||||
private float jumpSpeed = 12f;
|
||||
private boolean grounded = false;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateCameraDirection() {
|
||||
// build direction vector from yaw/pitch
|
||||
float yawRad = (float) Math.toRadians(yaw);
|
||||
float pitchRad = (float) Math.toRadians(pitch);
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 0–1: 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.PerspectiveCamera;
|
||||
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.SpriteBatch;
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator;
|
||||
import wtf.beatrice.retrorender.engine.render.DayNightCycle;
|
||||
|
||||
public class DebugHud {
|
||||
|
||||
private final SpriteBatch batch;
|
||||
private final BitmapFont font;
|
||||
private DayNightCycle dayNightCycle;
|
||||
private final Texture pixel;
|
||||
|
||||
public DebugHud(DayNightCycle dayNightCycle) {
|
||||
this.dayNightCycle = dayNightCycle;
|
||||
|
||||
public DebugHud() {
|
||||
batch = new SpriteBatch();
|
||||
|
||||
FreeTypeFontGenerator generator =
|
||||
@@ -38,6 +44,13 @@ public class DebugHud {
|
||||
|
||||
font = 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();
|
||||
}
|
||||
|
||||
// render HUD in virtual resolution
|
||||
@@ -49,24 +62,54 @@ public class DebugHud {
|
||||
|
||||
batch.begin();
|
||||
|
||||
String fpsText = Gdx.graphics.getFramesPerSecond() + " FPS";
|
||||
font.draw(batch, fpsText, 5f, height - 5f);
|
||||
|
||||
if (camera != null) {
|
||||
float x = camera.position.x;
|
||||
float y = camera.position.y;
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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() {
|
||||
batch.dispose();
|
||||
font.dispose();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package wtf.beatrice.retrorender.engine.ui;
|
||||
|
||||
public enum UiMode {
|
||||
GAMEPLAY,
|
||||
PAUSE, // only bottom bar
|
||||
SETTINGS // pause bar + center settings
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public class Lwjgl3Launcher {
|
||||
//// 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.
|
||||
|
||||
configuration.setWindowedMode(640, 480);
|
||||
configuration.setWindowedMode(860, 480);
|
||||
|
||||
//// You can change these files; they are in lwjgl3/src/main/resources/ .
|
||||
//// They can also be loaded from the root of assets/ .
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
// Applies the foojay-resolver plugin to allow automatic download of JDKs.
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
|
||||
}
|
||||
// A list of which subprojects to load as part of the same larger project.
|
||||
// You can remove Strings from the list and reload the Gradle project
|
||||
|
||||
Reference in New Issue
Block a user