initial engine commit

This commit is contained in:
2025-11-14 01:09:10 +01:00
parent 504f51fbab
commit f5bacf68d4
49 changed files with 1913 additions and 0 deletions

13
core/build.gradle Normal file
View File

@@ -0,0 +1,13 @@
[compileJava, compileTestJava]*.options*.encoding = 'UTF-8'
eclipse.project.name = appName + '-core'
dependencies {
api "com.badlogicgames.gdx-controllers:gdx-controllers-core:$gdxControllersVersion"
api "com.badlogicgames.gdx:gdx-bullet:$gdxVersion"
api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion"
api "com.badlogicgames.gdx:gdx:$gdxVersion"
if(enableGraalNative == 'true') {
implementation "io.github.berstanio:gdx-svmhelper-annotations:$graalHelperVersion"
}
}

View File

@@ -0,0 +1,200 @@
package wtf.beatrice.retrorender;
import com.badlogic.gdx.Gdx;
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 = 180;
public GameScreen(Main game) {
this.game = game;
}
private void initCamera() {
camera = new PerspectiveCamera(
67f,
Gdx.graphics.getWidth(),
Gdx.graphics.getHeight()
);
// 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 (render distance)
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);
// --- 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
hud.render(RETRO_WIDTH, RETRO_HEIGHT, camera);
frameBuffer.end();
// -- scale framebuffer to screen
int screenW = Gdx.graphics.getWidth();
int screenH = Gdx.graphics.getHeight();
int fbWidth = Gdx.graphics.getBackBufferWidth();
int fbHeight = Gdx.graphics.getBackBufferHeight();
Gdx.gl.glViewport(0, 0, fbWidth, fbHeight);
Gdx.gl.glClearColor(0f, 0f, 0f, 1f);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
screenBatch.begin();
// draw retro texture stretched to window size
screenBatch.draw(frameRegion, 0, 0, screenW, screenH);
screenBatch.end();
}
@Override
public void resize(int width, int height) {
camera.viewportWidth = width;
camera.viewportHeight = 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();
if (shadowBatch != null) shadowBatch.dispose();
if (shadowLight != null) shadowLight.dispose();
}
}

View File

@@ -0,0 +1,24 @@
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;
/** {@link com.badlogic.gdx.ApplicationListener} implementation shared by all platforms. */
public class Main extends Game
{
@Override
public void create()
{
setScreen(new GameScreen(this));
}
}

View File

@@ -0,0 +1,69 @@
package wtf.beatrice.retrorender.engine;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.PerspectiveCamera;
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;
public class DebugHud {
private final SpriteBatch batch;
private final BitmapFont font;
public DebugHud() {
batch = new SpriteBatch();
FreeTypeFontGenerator generator =
new FreeTypeFontGenerator(Gdx.files.internal("fonts/red-hat-mono.ttf"));
FreeTypeFontGenerator.FreeTypeFontParameter param =
new FreeTypeFontGenerator.FreeTypeFontParameter();
param.size = 10; // size in defined scaled framebuffer
param.color = Color.WHITE;
// reduce aliasing and smoothing
param.mono = true; // 1-bit glyphs, no grayscale AA
param.hinting = FreeTypeFontGenerator.Hinting.None; // no font hinting
param.borderWidth = 0.2f;
param.borderColor = Color.WHITE;
param.shadowOffsetX = 0;
param.shadowOffsetY = 0;
// sample font texture as pixels
param.minFilter = Texture.TextureFilter.Nearest;
param.magFilter = Texture.TextureFilter.Nearest;
font = generator.generateFont(param);
generator.dispose();
}
// render HUD in virtual resolution
public void render(int width, int height, PerspectiveCamera camera) {
// set up orthographic projection matching target size
batch.getProjectionMatrix().setToOrtho2D(0, 0, width, height);
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);
font.draw(batch, posText, 5f, height - 15f);
}
batch.end();
}
public void dispose() {
batch.dispose();
font.dispose();
}
}

View File

@@ -0,0 +1,241 @@
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.6f;
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 void setMoveSpeed(float moveSpeed) { this.moveSpeed = moveSpeed; }
public void setMouseSensitivity(float mouseSensitivity) { this.mouseSensitivity = mouseSensitivity; }
}

View File

@@ -0,0 +1,196 @@
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 = 20f;
// times the texture repeats across the whole plane
float tileScale = 8f;
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();
}
}