diff options
| author | Elizabeth Hunt <me@liz.coffee> | 2026-01-23 20:22:30 -0800 |
|---|---|---|
| committer | Elizabeth Hunt <me@liz.coffee> | 2026-01-23 20:22:30 -0800 |
| commit | 52864cb701e59a1d847fd5586245519eb5e3b3bc (patch) | |
| tree | 1d3df85b939e2c50ebf154ab4fcac6f02ad087c2 /core/src/main/java/coffee/liz/abstractionengine | |
| download | the-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')
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)); + }); + } +} |
