aboutsummaryrefslogtreecommitdiff
path: root/core/src/main/java/coffee/liz/abstractionengine
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2026-01-23 20:22:30 -0800
committerElizabeth Hunt <me@liz.coffee>2026-01-23 20:22:30 -0800
commit52864cb701e59a1d847fd5586245519eb5e3b3bc (patch)
tree1d3df85b939e2c50ebf154ab4fcac6f02ad087c2 /core/src/main/java/coffee/liz/abstractionengine
downloadthe-abstraction-engine-v2-52864cb701e59a1d847fd5586245519eb5e3b3bc.tar.gz
the-abstraction-engine-v2-52864cb701e59a1d847fd5586245519eb5e3b3bc.zip
Move code over
Diffstat (limited to 'core/src/main/java/coffee/liz/abstractionengine')
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java36
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java61
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java29
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java13
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java71
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java53
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java156
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java52
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java14
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java27
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java65
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java10
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java37
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java17
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java16
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java55
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java113
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java10
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java31
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java31
20 files changed, 897 insertions, 0 deletions
diff --git a/core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java b/core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java
new file mode 100644
index 0000000..c6e7a49
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java
@@ -0,0 +1,36 @@
+package coffee.liz.abstractionengine;
+
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.system.GridCollisionPropagatationSystem;
+import coffee.liz.abstractionengine.grid.system.GridIndexSystem;
+import coffee.liz.abstractionengine.grid.system.GridMovementSystem;
+import coffee.liz.abstractionengine.grid.system.GridPhysicsSystem;
+import coffee.liz.ecs.DAGWorld;
+import coffee.liz.ecs.math.Vec2;
+
+import lombok.extern.log4j.Log4j2;
+
+import java.time.Duration;
+import java.util.Map;
+
+/** Grid world implementation for the abstraction engine. */
+@Log4j2
+public class AbstractionEngineGridWorld extends DAGWorld<GridInputState> {
+ /** Initialize world with systems and player. */
+ public AbstractionEngineGridWorld(final Vec2<Integer> gridDimensions) {
+ super(Map.of(GridMovementSystem.class, new GridMovementSystem(), GridPhysicsSystem.class,
+ new GridPhysicsSystem(), GridIndexSystem.class, new GridIndexSystem(gridDimensions),
+ GridCollisionPropagatationSystem.class, new GridCollisionPropagatationSystem()));
+ }
+
+ /**
+ * Update world with input state. For now our grid world is a "step function",
+ * so we shouldn't (yet) care about the amount of time between steps.
+ *
+ * @param state
+ * the {@link GridInputState}
+ */
+ public void update(final GridInputState state) {
+ update(state, Duration.ZERO);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
new file mode 100644
index 0000000..a6cd6e9
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
@@ -0,0 +1,61 @@
+package coffee.liz.abstractionengine.app;
+
+import coffee.liz.abstractionengine.app.screen.Logo;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.math.Vec2f;
+import com.badlogic.gdx.Game;
+import com.badlogic.gdx.Gdx;
+import com.badlogic.gdx.graphics.Texture;
+import com.badlogic.gdx.graphics.g2d.BitmapFont;
+import com.badlogic.gdx.graphics.g2d.SpriteBatch;
+import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator;
+import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
+import com.badlogic.gdx.utils.viewport.FitViewport;
+
+public class AbstractionEngineGame extends Game {
+ public static final Vec2<Float> WORLD_SIZE = Vec2f.builder().x(200f).y(200f).build();
+ private static final String RENDERABLE_CHARS = "λ" + FreeTypeFontGenerator.DEFAULT_CHARS;
+
+ public SpriteBatch batch;
+ public BitmapFont font;
+ public FitViewport viewport;
+ public ShapeRenderer shapeRenderer;
+
+ public void create() {
+ viewport = new FitViewport(WORLD_SIZE.getX(), WORLD_SIZE.getY());
+ batch = new SpriteBatch();
+ shapeRenderer = new ShapeRenderer();
+ font = initFont(24);
+
+ this.setScreen(new Logo(this));
+
+ viewport.update(Gdx.graphics.getWidth(), Gdx.graphics.getHeight(), true);
+ }
+
+ public void render() {
+ super.render();
+ }
+
+ public void dispose() {
+ batch.dispose();
+ font.dispose();
+ shapeRenderer.dispose();
+ }
+
+ private BitmapFont initFont(final int size) {
+ final FreeTypeFontGenerator gen = new FreeTypeFontGenerator(
+ Gdx.files.internal("fonts/JetBrainsMonoNerdFont-Regular.ttf"));
+ final FreeTypeFontGenerator.FreeTypeFontParameter params = new FreeTypeFontGenerator.FreeTypeFontParameter();
+ params.characters = RENDERABLE_CHARS;
+ params.size = size;
+
+ final BitmapFont font = gen.generateFont(params);
+ font.setFixedWidthGlyphs(RENDERABLE_CHARS);
+ font.setUseIntegerPositions(false);
+ font.getData().setScale(WORLD_SIZE.getY() / Gdx.graphics.getHeight());
+ font.getRegion().getTexture().setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
+
+ gen.dispose();
+ return font;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java b/core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java
new file mode 100644
index 0000000..94cfdd8
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java
@@ -0,0 +1,29 @@
+package coffee.liz.abstractionengine.app.life;
+
+import coffee.liz.ecs.model.Component;
+import lombok.Getter;
+
+@Getter
+public class CellState implements Component {
+ private static final float EPS = 1.0e-4f;
+
+ public static final CellState LIVE = new CellState(1.0f);
+ public static final CellState DEAD = new CellState(0.0f);
+
+ private final float decay;
+
+ public CellState(final float decay) {
+ this.decay = clamp(decay);
+ }
+
+ public boolean isAlive() {
+ return decay >= (1.0f - EPS);
+ }
+
+ private static float clamp(final float value) {
+ if (value <= 0.0f) {
+ return 0.0f;
+ }
+ return Math.min(value, 1.0f);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java
new file mode 100644
index 0000000..2213ecc
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java
@@ -0,0 +1,13 @@
+package coffee.liz.abstractionengine.app.life;
+
+import coffee.liz.ecs.math.Vec2;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Set;
+
+@RequiredArgsConstructor
+@Data
+public class LifeInput {
+ private final Set<Vec2<Integer>> forceAliveCells;
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java
new file mode 100644
index 0000000..803fe58
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java
@@ -0,0 +1,71 @@
+package coffee.liz.abstractionengine.app.life;
+
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.abstractionengine.grid.system.BaseGridIndexSystem;
+import coffee.liz.ecs.math.Mat2;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import java.time.Duration;
+import java.util.Set;
+
+/** Conway's game of life */
+public class LifeSystem extends BaseGridIndexSystem<LifeInput> {
+ public final static int MAX_DECAY_STEPS = 12;
+ private final static float DECAY_STEP = 1.0f / MAX_DECAY_STEPS;
+ private final static Vec2<Integer> CONVOLUTION_RADIUS = Vec2i.builder().x(1).y(1).build();
+
+ private final static Duration BETWEEN_UPDATES = Duration.ofMillis(200);
+
+ private Duration sinceUpdate = Duration.ZERO;
+
+ public LifeSystem(final Vec2<Integer> dimensions) {
+ super(dimensions);
+ }
+
+ @Override
+ public void update(final World<LifeInput> world, final LifeInput state, final Duration dt) {
+ super.update(world, state, dt);
+ state.getForceAliveCells().forEach(cell -> {
+ if (!inBounds(cell)) {
+ return;
+ }
+ final Set<Entity> entities = rows.get(cell.getY()).get(cell.getX());
+ entities.forEach(e -> e.add(CellState.LIVE));
+ });
+
+ sinceUpdate = sinceUpdate.plus(dt);
+ if (sinceUpdate.compareTo(BETWEEN_UPDATES) < 0) {
+ return;
+ }
+ sinceUpdate = Duration.ZERO;
+
+ Mat2.convolve(this.rows, CONVOLUTION_RADIUS, () -> 0, (entities, rel, prev) -> {
+ if (rel.equals(Vec2i.ZERO)) {
+ return prev;
+ }
+ return entities.stream().findFirst().map(entity -> entity.get(CellState.class))
+ .map(cellState -> prev + (cellState.isAlive() ? 1 : 0)).orElse(prev);
+ }, (entities, neighboringAliveCells) -> entities.stream().findFirst().map(entity -> {
+ final CellState cellState = entity.get(CellState.class);
+ final float decay = cellState.getDecay();
+
+ final boolean alive = cellState.isAlive();
+ final boolean diesNow = alive && (neighboringAliveCells < 2 || neighboringAliveCells > 3);
+ final boolean spawnsNow = !alive && neighboringAliveCells == 3;
+ final boolean stillAlive = alive && !diesNow;
+
+ final CellState nextState;
+ if (spawnsNow || stillAlive) {
+ nextState = CellState.LIVE;
+ } else {
+ nextState = new CellState(decay - DECAY_STEP);
+ }
+
+ entity.add(nextState);
+ return entity;
+ }));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java
new file mode 100644
index 0000000..82b637a
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java
@@ -0,0 +1,53 @@
+package coffee.liz.abstractionengine.app.screen;
+
+import coffee.liz.abstractionengine.app.AbstractionEngineGame;
+import com.badlogic.gdx.Screen;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@RequiredArgsConstructor
+@Log4j2
+public class Logo implements Screen {
+ private final AbstractionEngineGame game;
+ float secondsShown = 0;
+
+ @Override
+ public void show() {
+ }
+
+ @Override
+ public void render(final float delta) {
+ secondsShown += delta;
+ if (secondsShown < 2f) {
+ return;
+ }
+
+ log.info("Transition to main menu after {}", secondsShown);
+ game.setScreen(new MainMenu(game));
+ dispose();
+ }
+
+ @Override
+ public void resize(int width, int height) {
+
+ }
+
+ @Override
+ public void pause() {
+
+ }
+
+ @Override
+ public void resume() {
+ }
+
+ @Override
+ public void hide() {
+
+ }
+
+ @Override
+ public void dispose() {
+
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java
new file mode 100644
index 0000000..e02acf4
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java
@@ -0,0 +1,156 @@
+package coffee.liz.abstractionengine.app.screen;
+
+import coffee.liz.abstractionengine.app.AbstractionEngineGame;
+import coffee.liz.abstractionengine.app.life.CellState;
+import coffee.liz.abstractionengine.app.life.LifeInput;
+import coffee.liz.abstractionengine.app.life.LifeSystem;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.DAGWorld;
+import coffee.liz.ecs.math.Mat2;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.math.Vec2f;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.World;
+import com.badlogic.gdx.Gdx;
+import com.badlogic.gdx.Screen;
+import com.badlogic.gdx.graphics.Color;
+import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
+import com.badlogic.gdx.math.Vector3;
+import com.badlogic.gdx.utils.ScreenUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static com.badlogic.gdx.math.MathUtils.clamp;
+
+@Log4j2
+@RequiredArgsConstructor
+public class MainMenu implements Screen {
+ private static final Vec2<Integer> DIMENSIONS = Vec2i.builder().x(50).y(50).build();
+ private final World<LifeInput> world = new DAGWorld<>(Map.of(LifeSystem.class, new LifeSystem(DIMENSIONS)));
+ private final Color cellColor = new Color();
+
+ private final AbstractionEngineGame game;
+
+ @Override
+ public void show() {
+ final Set<Vec2<Integer>> glider = Set.of(Vec2i.builder().x(1).y(0).build(), Vec2i.builder().x(2).y(1).build(),
+ Vec2i.builder().x(0).y(2).build(), Vec2i.builder().x(1).y(2).build(),
+ Vec2i.builder().x(2).y(2).build());
+ Mat2.init(DIMENSIONS, pos -> world.createEntity().add(new GridPosition(pos))
+ .add(glider.contains(pos) ? CellState.LIVE : CellState.DEAD));
+ }
+
+ @Override
+ public void render(final float delta) {
+ game.viewport.apply();
+
+ final Vec2<Float> cellSize = Vec2f.builder().x(game.viewport.getWorldWidth() / DIMENSIONS.getX())
+ .y(game.viewport.getWorldHeight() / DIMENSIONS.getY()).build();
+ final Set<Vec2<Integer>> forcedAlive = computeForcedAlive(cellSize);
+
+ world.update(new LifeInput(forcedAlive), Duration.ofMillis((int) (delta * 1000)));
+ ScreenUtils.clear(Color.CLEAR);
+
+ drawCells(cellSize);
+
+ // game.batch.setProjectionMatrix(game.viewport.getCamera().combined);
+ //
+ // for (int i = 0; i < 200; i++) {
+ // game.shapeRenderer.rect(i, i, 1, 1);
+ // }
+ // game.shapeRenderer.end();
+ //
+ //
+ // game.batch.begin();
+ // game.font.setColor(Color.WHITE);
+ // game.font.draw(game.batch, "Welcome to Drop!!! ", 1, 50);
+ // game.font.draw(game.batch, "λλλTap anywhere to begin!", 1, 100);
+ // game.batch.end();
+ //
+ // // world.update(new LifeInput(List.of()), Duration.ofMillis((long)(delta *
+ // // 1_000)));
+ // //
+ // // world.query(Set.of(GridPosition.class,
+ // StepsSinceLive.class)).forEach(entity
+ // // -> {
+ // // final GridPosition gridPosition = entity.get(GridPosition.class);
+ // // final StepsSinceLive stepsSinceLive = entity.get(StepsSinceLive.class);
+ // //
+ // //
+ // // });
+ }
+
+ private Set<Vec2<Integer>> computeForcedAlive(final Vec2<Float> cellSize) {
+ final Vector3 cursorPosition = game.viewport.unproject(new Vector3(Gdx.input.getX(), Gdx.input.getY(), 0));
+ final Vec2<Float> cursorWorld = Vec2f.builder().x(cursorPosition.x).y(cursorPosition.y).build();
+ final float clampedX = clamp(cursorWorld.getX(), 0.0f, game.viewport.getWorldWidth() - 0.001f);
+ final float clampedY = clamp(cursorWorld.getY(), 0.0f, game.viewport.getWorldHeight() - 0.001f);
+ final Vec2<Integer> gridPosition = Vec2i.builder().x((int) (clampedX / cellSize.getX()))
+ .y((int) (clampedY / cellSize.getY())).build();
+ final Set<Vec2<Integer>> forcedAlive = new HashSet<>();
+ if (Gdx.input.isTouched()) {
+ Stream.of(gridPosition).flatMap(
+ pos -> Stream.of(Vec2i.ZERO, Vec2i.EAST, Vec2i.WEST, Vec2i.NORTH, Vec2i.SOUTH).map(pos::plus))
+ .forEach(forcedAlive::add);
+ }
+ return forcedAlive;
+ }
+
+ private void drawCells(final Vec2<Float> cellSize) {
+ game.shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
+ game.shapeRenderer.setProjectionMatrix(game.viewport.getCamera().combined);
+ world.query(Set.of(GridPosition.class, CellState.class)).forEach(entity -> {
+ final CellState state = entity.get(CellState.class);
+ final Vec2<Integer> gridPos = entity.get(GridPosition.class).getPosition();
+ final Vec2<Float> worldPos = Vec2f.builder().x(gridPos.getX() * cellSize.getX())
+ .y(gridPos.getY() * cellSize.getY()).build();
+
+ final float decay = state.getDecay();
+ if (decay <= 0.0f) {
+ return;
+ }
+ game.shapeRenderer.setColor(interpolateColor(decay));
+ game.shapeRenderer.rect(worldPos.getX(), worldPos.getY(), cellSize.getX(), cellSize.getY());
+ });
+ game.shapeRenderer.end();
+ }
+
+ private Color interpolateColor(final float decay) {
+ final float bright = decay * decay * decay;
+ final float r = 0.02f + (0.98f * bright);
+ final float g = 0.02f + (0.98f * bright);
+ final float b = 0.02f + (0.98f * bright);
+ return cellColor.set(r, g, b, 1.0f);
+ }
+
+ @Override
+ public void resize(final int width, final int height) {
+ game.viewport.update(width, height, true);
+ }
+
+ @Override
+ public void pause() {
+
+ }
+
+ @Override
+ public void resume() {
+
+ }
+
+ @Override
+ public void hide() {
+
+ }
+
+ @Override
+ public void dispose() {
+ world.query(Set.of()).forEach(world::removeEntity);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java
new file mode 100644
index 0000000..e0bf243
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java
@@ -0,0 +1,52 @@
+package coffee.liz.abstractionengine.entity;
+
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior;
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior.CollisionBehaviorType;
+import coffee.liz.ecs.model.Component;
+
+/**
+ * Entity type in the
+ * {@link coffee.liz.abstractionengine.AbstractionEngineGridWorld}
+ */
+public enum EntityType implements Component {
+ /** Player entity */
+ PLAYER,
+ /** Wall entity. Nothing goes through this */
+ WALL,
+ /** Lambda term producer */
+ PRODUCER,
+ /** Lambda abstraction */
+ ABSTRACTION,
+ /** Lambda application */
+ APPLICATION,
+ /** Lava */
+ LAVA,
+ /** Bridge */
+ BRIDGE;
+
+ /**
+ * Gets the collision behavior to apply based on the colliding types.
+ *
+ * @param that
+ * is the {@link EntityType} colliding.
+ * @return collision behavior to resolve.
+ */
+ public CollisionBehavior collideWith(final EntityType that) {
+ if (that.equals(WALL)) {
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.WALL).priority(0).build();
+ }
+ if (this.equals(ABSTRACTION) && that.equals(APPLICATION)) {
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(0).build();
+ }
+
+ if (that.equals(LAVA)) {
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(1).build();
+ }
+ if (that.equals(BRIDGE)) { // BRIDGE takes precedence over LAVA, so stuff can walk on a bridge over
+ // LAVA
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(0).build();
+ }
+
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.PROPAGATE).priority(0).build();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java
new file mode 100644
index 0000000..c75b717
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java
@@ -0,0 +1,14 @@
+package coffee.liz.abstractionengine.entity;
+
+import coffee.liz.abstractionengine.grid.component.GridCollidable;
+import coffee.liz.ecs.model.Entity;
+
+import lombok.Data;
+
+@Data
+public class EntityTypeGridCollidable implements GridCollidable {
+ @Override
+ public CollisionBehavior getCollisionBehaviorBetween(final Entity me, final Entity them) {
+ return me.get(EntityType.class).collideWith(them.get(EntityType.class));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java b/core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java
new file mode 100644
index 0000000..240a94e
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java
@@ -0,0 +1,27 @@
+package coffee.liz.abstractionengine.entity;
+
+import coffee.liz.abstractionengine.grid.component.GridControllable;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+/** Factory for creating player {@link Entity}s. */
+public final class PlayerFactory {
+ private PlayerFactory() {
+ }
+
+ /**
+ * Create a player entity.
+ *
+ * @param world
+ * the {@link World} to create in
+ * @param position
+ * the starting {@link Vec2} position
+ * @return created player {@link Entity}
+ */
+ public static Entity addToWorld(final World<?> world, final Vec2<Integer> position) {
+ return world.createEntity().add(new GridPosition(position)).add(new GridControllable()).add(EntityType.PLAYER)
+ .add(new EntityTypeGridCollidable());
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java
new file mode 100644
index 0000000..e72d4e8
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java
@@ -0,0 +1,65 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.model.Component;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+
+/** {@link Component} for grid collision handling. */
+public interface GridCollidable extends Component {
+ @RequiredArgsConstructor
+ @Data
+ @Builder
+ class CollisionBehavior implements Comparable<CollisionBehavior> {
+ private final CollisionBehaviorType collisionBehaviorType;
+ private final int priority;
+
+ public int compareTo(final CollisionBehavior other) {
+ return Integer.compare(getPriority(), other.getPriority());
+ }
+
+ public enum CollisionBehaviorType {
+ /** Propagate collision to next entity. */
+ PROPAGATE,
+ /** Block collision like a wall. */
+ WALL,
+ /** Swallow the colliding entity. */
+ SWALLOW;
+ }
+ }
+
+ /**
+ * Get collision behavior for colliding entity.
+ *
+ * @param me
+ * the staring colliding {@link Entity}
+ * @param them
+ * the colliding {@link Entity}
+ * @return the {@link CollisionBehavior}
+ */
+ CollisionBehavior getCollisionBehaviorBetween(final Entity me, final Entity them);
+
+ /**
+ * Handle swallowing an entity.
+ *
+ * @param them
+ * the {@link Entity} being swallowed
+ * @param world
+ * the {@link World} to modify
+ */
+ default <T> void onSwallow(final Entity them, final World<T> world) {
+ throw new UnsupportedOperationException("Does not swallow"); // ...could not be me~ (つ﹏⊂)
+ }
+
+ /**
+ * Anything that implements GridCollidable should be keyed by GridCollidable.
+ *
+ * @return Key type
+ */
+ default Class<? extends Component> getKey() {
+ return GridCollidable.class;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java
new file mode 100644
index 0000000..b4a9ba9
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java
@@ -0,0 +1,10 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.model.Component;
+
+import lombok.Data;
+
+/** {@link Component} marking an entity as player-controllable. */
+@Data
+public class GridControllable implements Component {
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java
new file mode 100644
index 0000000..8fadb09
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java
@@ -0,0 +1,37 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Entity;
+
+import coffee.liz.lambda.ast.LambdaProgram;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import jakarta.annotation.Nullable;
+
+/** {@link coffee.liz.ecs.model.Component} for player input state. */
+@Getter
+@AllArgsConstructor
+@Builder
+public class GridInputState {
+ /** Movement {@link Vec2} direction. */
+ @Nullable
+ private final Vec2<Integer> movement;
+
+ /** Lambda term update to apply. */
+ @Nullable
+ private final UpdateLambdaTerm updateLambdaTerm;
+
+ /** Update lambda term with target entity. */
+ @Getter
+ @RequiredArgsConstructor
+ public static class UpdateLambdaTerm {
+ /** {@link Entity} to update. */
+ private final Entity toUpdate;
+
+ /** {@link LambdaProgram} to apply. */
+ private final LambdaProgram lambdaProgram;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java
new file mode 100644
index 0000000..aec26b1
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java
@@ -0,0 +1,17 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Component;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/** {@link Component} for grid momentum/velocity. */
+@RequiredArgsConstructor
+@Data
+@Getter
+public class GridMomentum implements Component {
+ /** Velocity {@link Vec2}. */
+ private final Vec2<Integer> velocity;
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java
new file mode 100644
index 0000000..4ec0557
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java
@@ -0,0 +1,16 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Component;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/** {@link Component} for grid position. */
+@RequiredArgsConstructor
+@Data
+@Getter
+public class GridPosition implements Component {
+ private final Vec2<Integer> position;
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java
new file mode 100644
index 0000000..61044f6
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java
@@ -0,0 +1,55 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Mat2;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** System which maintains a lookup of the grid. */
+@RequiredArgsConstructor
+@Getter
+public abstract class BaseGridIndexSystem<T> implements System<T> {
+ protected final Vec2<Integer> dimensions;
+ protected final List<List<Set<Entity>>> rows;
+
+ public BaseGridIndexSystem(final Vec2<Integer> dimensions) {
+ this.dimensions = dimensions;
+ this.rows = Mat2.init(dimensions, pos -> new HashSet<>());
+ }
+
+ @Override
+ public Collection<Class<? extends System<T>>> getDependencies() {
+ return Set.of();
+ }
+
+ @Override
+ public void update(final World<T> world, final T state, final Duration dt) {
+ rows.forEach(row -> row.forEach(Set::clear));
+ world.query(Set.of(GridPosition.class)).forEach(entity -> {
+ final GridPosition position = entity.get(GridPosition.class);
+ rows.get(position.getPosition().getY()).get(position.getPosition().getX()).add(entity);
+ });
+ }
+
+ public Collection<Entity> entitiesAt(final Vec2<Integer> pos) {
+ if (!inBounds(pos)) {
+ return Collections.emptySet();
+ }
+ return Set.copyOf(rows.get(pos.getY()).get(pos.getX()));
+ }
+
+ public boolean inBounds(final Vec2<Integer> pos) {
+ return pos.getX() >= 0 && pos.getX() < dimensions.getX() && pos.getY() >= 0 && pos.getY() < dimensions.getY();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java
new file mode 100644
index 0000000..9d07b59
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java
@@ -0,0 +1,113 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridCollidable;
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior;
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridMomentum;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * System which resolves collisions between cells in the Grid.
+ */
+public class GridCollisionPropagatationSystem implements System<GridInputState> {
+ @Override
+ public Collection<Class<? extends System<GridInputState>>> getDependencies() {
+ return Set.of(GridMovementSystem.class, GridIndexSystem.class);
+ }
+
+ @Override
+ public void update(final World<GridInputState> world, final GridInputState state, final Duration dt) {
+ final GridIndexSystem indexSystem = world.getSystem(GridIndexSystem.class);
+ world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)).forEach(pusher -> {
+ final Vec2<Integer> velocity = pusher.get(GridMomentum.class).getVelocity();
+ if (velocity.equals(Vec2i.ZERO)) {
+ return;
+ }
+
+ final Vec2<Integer> position = pusher.get(GridPosition.class).getPosition();
+ final CollisionRayResult result = resolveCollisionRay(world, pusher, position, velocity);
+
+ pusher.add(new GridMomentum(Vec2i.ZERO));
+ if (result instanceof CollisionRayResult.Blocked) {
+ return;
+ }
+
+ final CollisionRayResult.Propagated propagated = (CollisionRayResult.Propagated) result;
+ propagated.ray().forEach(e -> e.add(new GridMomentum(propagated.direction())));
+ propagated.swallows()
+ .forEach(entry -> entry.getKey().get(GridCollidable.class).onSwallow(entry.getValue(), world));
+ });
+ }
+
+ private CollisionRayResult resolveCollisionRay(World<GridInputState> world, Entity pusher, Vec2<Integer> start,
+ Vec2<Integer> direction) {
+ final GridIndexSystem gridIndex = world.getSystem(GridIndexSystem.class);
+
+ final List<List<Entity>> ray = new ArrayList<>();
+ ray.add(List.of(pusher));
+ final Set<Map.Entry<Entity, Entity>> swallows = new HashSet<>();
+
+ Vec2<Integer> gridPosition = start.plus(direction);
+ while (!ray.getLast().isEmpty()) {
+ if (!gridIndex.inBounds(gridPosition)) {
+ return new CollisionRayResult.Blocked();
+ }
+
+ final List<Entity> collidables = gridIndex.entitiesAt(gridPosition).stream()
+ .filter(e -> e.has(GridCollidable.class)).toList();
+ if (collidables.isEmpty()) {
+ break;
+ }
+
+ final List<Entity> nextRay = new ArrayList<>();
+
+ for (final Entity push : ray.getLast()) {
+ final Map.Entry<Entity, CollisionBehavior> behavior = collidables.stream()
+ .map(c -> Map.entry(c, c.get(GridCollidable.class).getCollisionBehaviorBetween(push, c)))
+ .min(Comparator.comparing(e -> e.getValue().getPriority())).orElseThrow();
+
+ switch (behavior.getValue().getCollisionBehaviorType()) {
+ case PROPAGATE -> {
+ nextRay.add(behavior.getKey());
+ }
+ case SWALLOW -> {
+ swallows.add(Map.entry(behavior.getKey(), push));
+ }
+ case WALL -> {
+ return new CollisionRayResult.Blocked();
+ }
+ }
+ }
+
+ ray.add(nextRay);
+ gridPosition = gridPosition.plus(direction);
+ }
+
+ final Collection<Entity> rayEntities = ray.stream().flatMap(Collection::stream).toList();
+ return new CollisionRayResult.Propagated(rayEntities, swallows, direction);
+ }
+
+ private sealed interface CollisionRayResult permits CollisionRayResult.Propagated, CollisionRayResult.Blocked {
+
+ record Propagated(Collection<Entity> ray, Set<Map.Entry<Entity, Entity>> swallows,
+ Vec2<Integer> direction) implements CollisionRayResult {
+ }
+
+ record Blocked() implements CollisionRayResult {
+ }
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java
new file mode 100644
index 0000000..ca2279f
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java
@@ -0,0 +1,10 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.ecs.math.Vec2;
+
+public class GridIndexSystem extends BaseGridIndexSystem<GridInputState> {
+ public GridIndexSystem(final Vec2<Integer> dimensions) {
+ super(dimensions);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java
new file mode 100644
index 0000000..f5f1089
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java
@@ -0,0 +1,31 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridControllable;
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridMomentum;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Set;
+
+/** System handling player movement input. */
+@RequiredArgsConstructor
+@Getter
+public class GridMovementSystem implements System<GridInputState> {
+ @Override
+ public Collection<Class<? extends System<GridInputState>>> getDependencies() {
+ return Set.of();
+ }
+
+ @Override
+ public void update(final World<GridInputState> world, final GridInputState state, final Duration dt) {
+ world.query(Set.of(GridControllable.class, GridPosition.class))
+ .forEach(entity -> entity.add(new GridMomentum(state.getMovement())));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java
new file mode 100644
index 0000000..88f8d57
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java
@@ -0,0 +1,31 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridMomentum;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Set;
+
+/** System applying physics/position updates from momentum. */
+public class GridPhysicsSystem implements System<GridInputState> {
+ @Override
+ public Collection<Class<? extends System<GridInputState>>> getDependencies() {
+ return Set.of(GridCollisionPropagatationSystem.class);
+ }
+
+ @Override
+ public void update(final World<GridInputState> world, final GridInputState state, final Duration dt) {
+ world.query(Set.of(GridMomentum.class, GridPosition.class)).forEach(entity -> {
+ final GridMomentum momentum = entity.get(GridMomentum.class);
+ final GridPosition position = entity.get(GridPosition.class);
+
+ entity.add(new GridPosition(position.getPosition().plus(momentum.getVelocity())));
+ entity.add(new GridMomentum(Vec2i.ZERO));
+ });
+ }
+}