refactor world logic

This commit is contained in:
2025-11-14 22:35:33 +01:00
parent 398f1fa26f
commit 7a599ab1e5
6 changed files with 286 additions and 133 deletions

View File

@@ -49,6 +49,9 @@ public class GameScreen implements Screen {
private TextureRegion frameRegion; private TextureRegion frameRegion;
private SpriteBatch screenBatch; private SpriteBatch screenBatch;
// world
private ModelLibrary modelLibrary;
private static final int RETRO_WIDTH = 320; private static final int RETRO_WIDTH = 320;
private static final int RETRO_HEIGHT = 240; private static final int RETRO_HEIGHT = 240;
@@ -94,11 +97,14 @@ public class GameScreen implements Screen {
// create the cycle controller // create the cycle controller
dayNightCycle = new DayNightCycle(shadowLight, ambientLight); dayNightCycle = new DayNightCycle(shadowLight, ambientLight);
// optional: start at morning/noon/etc // optional: start at morning/noon/etc
dayNightCycle.setTimeOfDay(0.25f); // sunrise dayNightCycle.setTimeOfDay(0.4f); // good sun
dayNightCycle.setAxialTilt(30f);
dayNightCycle.setDayLength(60f * 5f); // 20 minutes
} }
private void initWorld() { private void initWorld() {
world = new World3D(); modelLibrary = new ModelLibrary();
world = new World3D(modelLibrary);
} }
private void initMenus() { private void initMenus() {

View File

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

View File

@@ -28,7 +28,7 @@ public class DayNightCycle {
// Rotate around Z so the sun path is tilted relative to world up. // Rotate around Z so the sun path is tilted relative to world up.
private final Vector3 tiltAxis = new Vector3(0f, 0f, 1f).nor(); private final Vector3 tiltAxis = new Vector3(0f, 0f, 1f).nor();
public float axialTiltDeg = 30f; // tweak to taste private float axialTilt = 30f; // in degrees
/** /**
* If false (default), simple equator-style model: * If false (default), simple equator-style model:
@@ -43,7 +43,7 @@ public class DayNightCycle {
* If true, brightness and day length depend on the final tilted direction, * 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. * so axialTiltDeg affects how long days are and how high the sun gets.
*/ */
public boolean seasonal = false; private boolean seasonal = false;
/** /**
* 01: 0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset. * 01: 0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset.
@@ -51,7 +51,7 @@ public class DayNightCycle {
private float timeOfDay = 0.25f; private float timeOfDay = 0.25f;
/** Seconds for one full 24h cycle. */ /** Seconds for one full 24h cycle. */
public float dayLengthSeconds = 60 * 5f; private float dayLengthSeconds = 60 * 5f;
/** /**
* Daylight / altitude factor in [0, 1]. * Daylight / altitude factor in [0, 1].
@@ -98,7 +98,7 @@ public class DayNightCycle {
// Visual direction with axial tilt // Visual direction with axial tilt
tmpDir.set(baseX, baseY, baseZ).nor(); tmpDir.set(baseX, baseY, baseZ).nor();
tmpDir.rotate(tiltAxis, axialTiltDeg); tmpDir.rotate(tiltAxis, axialTilt);
sunDirection.set(tmpDir); sunDirection.set(tmpDir);
// Sun color / intensity // Sun color / intensity
@@ -116,7 +116,7 @@ public class DayNightCycle {
// === Seasonal / physical mode === // === Seasonal / physical mode ===
tmpDir.set(baseX, baseY, baseZ).nor(); tmpDir.set(baseX, baseY, baseZ).nor();
tmpDir.rotate(tiltAxis, axialTiltDeg); tmpDir.rotate(tiltAxis, axialTilt);
sunDirection.set(tmpDir); sunDirection.set(tmpDir);
// True altitude from final direction: 0..1 // True altitude from final direction: 0..1
@@ -166,4 +166,24 @@ public class DayNightCycle {
public boolean isSunAboveHorizon() { public boolean isSunAboveHorizon() {
return sunAltitude > 0f; // strictly > 0; you can use >= 0.01f if you want a tiny cutoff return sunAltitude > 0f; // strictly > 0; you can use >= 0.01f if you want a tiny cutoff
} }
public float getAxialTilt()
{
return axialTilt;
}
public void setAxialTilt(float degrees)
{
axialTilt = degrees;
}
public float getDayLength()
{
return dayLengthSeconds;
}
public void setDayLength(float seconds)
{
dayLengthSeconds = seconds;
}
} }

View File

@@ -0,0 +1,68 @@
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;
public class ModelLibrary {
private final ModelBuilder builder = new ModelBuilder();
public final Texture groundTexture;
public final Model groundModel;
public final Model unitCubeModel;
public ModelLibrary() {
// ground 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);
// ground model (32x32 plane)
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();
// a generic 2x2x2 cube model (can be reused for buildings, crates, etc.)
unitCubeModel = builder.createBox(
2f, 2f, 2f,
new Material(ColorAttribute.createDiffuse(1f, 1f, 1f, 1f)),
VertexAttributes.Usage.Position | VertexAttributes.Usage.Normal
);
}
public void dispose() {
groundModel.dispose();
unitCubeModel.dispose();
groundTexture.dispose();
}
}

View File

@@ -1,14 +1,8 @@
package wtf.beatrice.retrorender.engine; package wtf.beatrice.retrorender.engine;
import com.badlogic.gdx.Gdx; import com.badlogic.gdx.graphics.g3d.Environment;
import com.badlogic.gdx.graphics.GL20; import com.badlogic.gdx.graphics.g3d.ModelBatch;
import com.badlogic.gdx.graphics.Texture; import com.badlogic.gdx.math.Matrix4;
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 com.badlogic.gdx.math.Vector3;
import java.util.ArrayList; import java.util.ArrayList;
@@ -16,134 +10,121 @@ import java.util.List;
public class World3D { public class World3D {
// textures private final ModelLibrary models;
private final Texture groundTexture;
// models (base mesh) private final WorldObject ground;
private final Model groundModel; private final List<WorldObject> objects = new ArrayList<>();
private final Model cubeModel;
// instances
private final ModelInstance groundInstance;
private final List<ModelInstance> cubeInstances = new ArrayList<>();
public World3D() { private final Vector3 tmpWorld = new Vector3();
ModelBuilder modelBuilder = new ModelBuilder(); private final Vector3 tmpFeet = new Vector3();
private final Vector3 tmpHead = new Vector3();
private final Vector3 tmpLocalFeet = new Vector3();
private final Vector3 tmpLocalHead = new Vector3();
// --- load floor texture private final Matrix4 tmpMat = new Matrix4();
groundTexture = new Texture(Gdx.files.internal("textures/paving.png")); private final Matrix4 tmpInv = new Matrix4();
groundTexture.setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest);
groundTexture.setWrap(Texture.TextureWrap.Repeat, Texture.TextureWrap.Repeat);
// --- create ground material private final Vector3 tmp = new Vector3();
Material groundMat = new Material(
ColorAttribute.createDiffuse(1f, 1f, 1f, 1f),
TextureAttribute.createDiffuse(groundTexture)
);
// plane size (halfSize in X/Z) public World3D(ModelLibrary models) {
float halfSize = 32f; this.models = models;
// times the texture repeats across the whole plane // --- ground ---
float tileScale = 16f; ground = new WorldObject(
modelBuilder.begin();
MeshPartBuilder mpb = modelBuilder.part(
"ground", "ground",
GL20.GL_TRIANGLES, new com.badlogic.gdx.graphics.g3d.ModelInstance(models.groundModel),
VertexAttributes.Usage.Position Collider.none() // treat plane as baseHeight = 0, not as a collider
| VertexAttributes.Usage.Normal
| VertexAttributes.Usage.TextureCoordinates,
groundMat
); );
ground.staticObject = true;
// set UV range so texture repeats tileScale times // --- some cubes (temporary test geometry) ---
mpb.setUVRange(0f, 0f, tileScale, tileScale); addCube("center", 0f, 1f, 0f);
addCube("cube-ne", 8f, 1f, 8f);
addCube("cube-nw", -8f, 1f, 8f);
addCube("cube-se", 8f, 1f, -8f);
addCube("cube-sw", -8f, 1f, -8f);
// build the quad WorldObject pillar = addCube("pillar", 0f, 3f, -10f);
mpb.rect( // scale pillars transform (purely visual)
-halfSize, 0f, halfSize, // top-left pillar.instance.transform
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) .setToScaling(1f, 3f, 1f)
.translate(0f, 3f, -10f); .translate(0f, 3f, -10f);
cubeInstances.add(pillar); // collider is still 1x1x1, you can adjust collider.halfExtents here if needed
}
private WorldObject addCube(String id, float x, float y, float z) {
var instance = new com.badlogic.gdx.graphics.g3d.ModelInstance(models.unitCubeModel);
instance.transform.setToTranslation(x, y, z);
// cube is 2x2x2, so half extents = 1
Collider collider = Collider.box(1f, 1f, 1f);
WorldObject obj = new WorldObject(id, instance, collider);
obj.staticObject = true;
objects.add(obj);
return obj;
} }
public void update(float delta) { public void update(float delta) {
// rotate cubes around y axis // here you can update objects that animate / move
for (ModelInstance instance : cubeInstances) { for (WorldObject obj : objects) {
instance.transform.rotate(Vector3.Y, 20f * delta); obj.update(delta);
} }
} }
public void render(ModelBatch batch, Environment environment) { public void render(ModelBatch batch, Environment environment) {
// draw ground // draw ground
batch.render(groundInstance, environment); batch.render(ground.instance, environment);
// draw cubes
for (ModelInstance instance : cubeInstances) { // draw objects
batch.render(instance, environment); for (WorldObject obj : objects) {
batch.render(obj.instance, environment);
} }
} }
/** /**
* Returns the ground height (y) at a given XZ position. * 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. * Starts from baseHeight (e.g. 0) and checks if you're standing on top of any BOX collider.
*/ */
public float getGroundHeightAt(float x, float z, float baseHeight) { public float getGroundHeightAt(float x, float z, float baseHeight) {
float maxY = baseHeight; // ground plane at y = 0 float maxY = baseHeight; // plane at y = 0
// a cube is 2 units tall, from y = 0 to y = 2 for (WorldObject obj : objects) {
float cubeHalfSize = 1f; if (obj.collider.type != Collider.Type.BOX) continue;
float cubeTopY = 2f;
Vector3 tmp = new Vector3(); Collider col = obj.collider;
for (ModelInstance cube : cubeInstances) {
cube.transform.getTranslation(tmp);
float minX = tmp.x - cubeHalfSize; // Inverse transform: world → object local
float maxX = tmp.x + cubeHalfSize; tmpInv.set(obj.instance.transform);
float minZ = tmp.z - cubeHalfSize; tmpInv.inv();
float maxZ = tmp.z + cubeHalfSize;
if (x >= minX && x <= maxX && z >= minZ && z <= maxZ) { // Player XZ at ground level in world space
if (cubeTopY > maxY) { tmpWorld.set(x, 0f, z);
maxY = cubeTopY; tmpWorld.mul(tmpInv); // now in local space
}
float px = tmpWorld.x;
float pz = tmpWorld.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;
// inside horizontal footprint in *local* space
if (px < minX || px > maxX || pz < minZ || pz > maxZ) {
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;
} }
} }
@@ -151,36 +132,50 @@ public class World3D {
} }
/** /**
* Simple horizontal collision check between a player and cube sides. * Simple horizontal collision check between a player and BOX colliders.
* Player is approximated as a capsule: radius in XZ, height = eyeHeight. * Player is approximated as a capsule: radius in XZ, height = eyeHeight.
*/ */
public boolean collidesAt(float x, float y, float z, float radius, float eyeHeight) { public boolean collidesAt(float x, float y, float z, float radius, float eyeHeight) {
float feetY = y - eyeHeight; float feetY = y - eyeHeight;
float headY = y; float headY = y;
float cubeHalfSize = 1f; for (WorldObject obj : objects) {
float cubeBottomY = 0f; if (obj.collider.type != Collider.Type.BOX) continue;
float cubeTopY = 2f;
Vector3 tmp = new Vector3(); Collider col = obj.collider;
for (ModelInstance cube : cubeInstances) {
cube.transform.getTranslation(tmp);
float minX = tmp.x - cubeHalfSize; // world → local
float maxX = tmp.x + cubeHalfSize; tmpInv.set(obj.instance.transform);
float minZ = tmp.z - cubeHalfSize; tmpInv.inv();
float maxZ = tmp.z + cubeHalfSize;
// no vertical overlap -> ignore tmpFeet.set(x, feetY, z).mul(tmpInv);
if (headY <= cubeBottomY || feetY >= cubeTopY) { tmpHead.set(x, headY, z).mul(tmpInv);
// For horizontal test, use the "average" x/z of feet/head
float px = 0.5f * (tmpFeet.x + tmpHead.x);
float pz = 0.5f * (tmpFeet.z + tmpHead.z);
float boxMinY = col.center.y - col.halfExtents.y;
float boxMaxY = col.center.y + col.halfExtents.y;
// vertical overlap in local space
if (tmpHead.y <= boxMinY || tmpFeet.y >= boxMaxY) {
continue; continue;
} }
// circle (player) vs AABB (cube) in XZ float minX = col.center.x - col.halfExtents.x;
boolean overlapX = (x + radius > minX) && (x - radius < maxX); float maxX = col.center.x + col.halfExtents.x;
boolean overlapZ = (z + radius > minZ) && (z - radius < maxZ); float minZ = col.center.z - col.halfExtents.z;
float maxZ = col.center.z + col.halfExtents.z;
if (overlapX && overlapZ) { // circle (player) vs AABB in local XZ
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 true;
} }
} }
@@ -189,8 +184,6 @@ public class World3D {
} }
public void dispose() { public void dispose() {
groundModel.dispose(); models.dispose(); // or let GameScreen own/dispose the library
cubeModel.dispose();
groundTexture.dispose();
} }
} }

View File

@@ -0,0 +1,37 @@
package wtf.beatrice.retrorender.engine;
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;
// 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) {
instance.transform.rotate(Vector3.Y, delta * 20f);
// default: do nothing
// you can subclass or add strategies later for rotating, animating, AI, etc.
}
public Vector3 getPosition(Vector3 out) {
instance.transform.getTranslation(out);
return out;
}
public Vector3 getPosition() {
return getPosition(tmpPos);
}
}