From 6e2fb9b12870a24f99071ea726d1c49ed57593ad Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Fri, 27 Feb 2026 20:00:05 -0800 Subject: Moving penguin --- assets/player.png | Bin 0 -> 7535 bytes build.gradle | 16 ++ core/build.gradle | 6 +- core/src/main/java/coffee/liz/dyl/DylGame.java | 53 ++++++ core/src/main/java/coffee/liz/dyl/FirstScreen.java | 45 ----- core/src/main/java/coffee/liz/dyl/FrameState.java | 16 ++ core/src/main/java/coffee/liz/dyl/Main.java | 11 -- .../coffee/liz/dyl/components/Controllable.java | 6 + .../java/coffee/liz/dyl/components/Transform.java | 18 ++ .../java/coffee/liz/dyl/components/Velocity.java | 16 ++ .../coffee/liz/dyl/components/graphic/Graphic.java | 13 ++ .../liz/dyl/components/graphic/TextureGraphic.java | 21 +++ .../main/java/coffee/liz/dyl/config/KeyBinds.java | 41 ++++ .../main/java/coffee/liz/dyl/config/Settings.java | 16 ++ .../coffee/liz/dyl/entities/PlayerFactory.java | 31 +++ .../java/coffee/liz/dyl/screen/GameScreen.java | 51 +++++ .../java/coffee/liz/dyl/systems/InputSystem.java | 41 ++++ .../coffee/liz/dyl/systems/IntegrationSystem.java | 30 +++ .../java/coffee/liz/dyl/systems/RenderSystem.java | 53 ++++++ .../java/coffee/liz/dyl/world/DylGameWorld.java | 23 +++ core/src/main/java/coffee/liz/ecs/DAGWorld.java | 207 +++++++++++++++++++++ .../main/java/coffee/liz/ecs/model/Component.java | 8 + .../src/main/java/coffee/liz/ecs/model/Entity.java | 105 +++++++++++ core/src/main/java/coffee/liz/ecs/model/Query.java | 30 +++ .../java/coffee/liz/ecs/model/QueryBuilder.java | 25 +++ .../src/main/java/coffee/liz/ecs/model/System.java | 32 ++++ core/src/main/java/coffee/liz/ecs/model/World.java | 64 +++++++ .../java/coffee/liz/ecs/utils/FunctionUtils.java | 28 +++ .../src/test/java/coffee/liz/ecs/DAGWorldTest.java | 206 ++++++++++++++++++++ .../test/java/coffee/liz/ecs/model/EntityTest.java | 97 ++++++++++ design/design.txt | 53 ++++++ .../java/coffee/liz/dyl/lwjgl3/Lwjgl3Launcher.java | 8 +- 32 files changed, 1308 insertions(+), 62 deletions(-) create mode 100644 assets/player.png create mode 100644 core/src/main/java/coffee/liz/dyl/DylGame.java delete mode 100644 core/src/main/java/coffee/liz/dyl/FirstScreen.java create mode 100644 core/src/main/java/coffee/liz/dyl/FrameState.java delete mode 100644 core/src/main/java/coffee/liz/dyl/Main.java create mode 100644 core/src/main/java/coffee/liz/dyl/components/Controllable.java create mode 100644 core/src/main/java/coffee/liz/dyl/components/Transform.java create mode 100644 core/src/main/java/coffee/liz/dyl/components/Velocity.java create mode 100644 core/src/main/java/coffee/liz/dyl/components/graphic/Graphic.java create mode 100644 core/src/main/java/coffee/liz/dyl/components/graphic/TextureGraphic.java create mode 100644 core/src/main/java/coffee/liz/dyl/config/KeyBinds.java create mode 100644 core/src/main/java/coffee/liz/dyl/config/Settings.java create mode 100644 core/src/main/java/coffee/liz/dyl/entities/PlayerFactory.java create mode 100644 core/src/main/java/coffee/liz/dyl/screen/GameScreen.java create mode 100644 core/src/main/java/coffee/liz/dyl/systems/InputSystem.java create mode 100644 core/src/main/java/coffee/liz/dyl/systems/IntegrationSystem.java create mode 100644 core/src/main/java/coffee/liz/dyl/systems/RenderSystem.java create mode 100644 core/src/main/java/coffee/liz/dyl/world/DylGameWorld.java create mode 100644 core/src/main/java/coffee/liz/ecs/DAGWorld.java create mode 100644 core/src/main/java/coffee/liz/ecs/model/Component.java create mode 100644 core/src/main/java/coffee/liz/ecs/model/Entity.java create mode 100644 core/src/main/java/coffee/liz/ecs/model/Query.java create mode 100644 core/src/main/java/coffee/liz/ecs/model/QueryBuilder.java create mode 100644 core/src/main/java/coffee/liz/ecs/model/System.java create mode 100644 core/src/main/java/coffee/liz/ecs/model/World.java create mode 100644 core/src/main/java/coffee/liz/ecs/utils/FunctionUtils.java create mode 100644 core/src/test/java/coffee/liz/ecs/DAGWorldTest.java create mode 100644 core/src/test/java/coffee/liz/ecs/model/EntityTest.java create mode 100644 design/design.txt diff --git a/assets/player.png b/assets/player.png new file mode 100644 index 0000000..2e95386 Binary files /dev/null and b/assets/player.png differ diff --git a/build.gradle b/build.gradle index 072a5c9..d1e92d5 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,22 @@ configure(subprojects) { apply plugin: 'java-library' java.sourceCompatibility = 17 + dependencies { + compileOnly 'org.projectlombok:lombok:1.18.42' + compileOnly 'jakarta.annotation:jakarta.annotation-api:2.1.1' + annotationProcessor 'org.projectlombok:lombok:1.18.42' + testCompileOnly 'org.projectlombok:lombok:1.18.42' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.42' + + // JUnit 5 + Mockito testing + testImplementation platform('org.junit:junit-bom:5.14.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.mockito:mockito-core:5.8.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.8.0' + } + + // From https://lyze.dev/2021/04/29/libGDX-Internal-Assets-List/ // The article can be helpful when using assets.txt in your project. tasks.register('generateAssetList') { diff --git a/core/build.gradle b/core/build.gradle index a27dcd6..a917182 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -2,13 +2,15 @@ eclipse.project.name = appName + '-core' dependencies { - api "com.badlogicgames.ashley:ashley:$ashleyVersion" api "com.badlogicgames.box2dlights:box2dlights:$box2dlightsVersion" - api "com.badlogicgames.gdx:gdx-ai:$aiVersion" api "com.badlogicgames.gdx:gdx-box2d:$gdxVersion" api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion" api "com.badlogicgames.gdx:gdx:$gdxVersion" + implementation 'org.apache.logging.log4j:log4j-api:2.25.3' + implementation 'org.apache.logging.log4j:log4j-core:2.25.3' + implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.25.3' + if(enableGraalNative == 'true') { implementation "io.github.berstanio:gdx-svmhelper-annotations:$graalHelperVersion" } diff --git a/core/src/main/java/coffee/liz/dyl/DylGame.java b/core/src/main/java/coffee/liz/dyl/DylGame.java new file mode 100644 index 0000000..aa25617 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/DylGame.java @@ -0,0 +1,53 @@ +package coffee.liz.dyl; + +import coffee.liz.dyl.config.Settings; +import coffee.liz.dyl.screen.GameScreen; +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.InputMultiplexer; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.OrthographicCamera; +import com.badlogic.gdx.graphics.g2d.Batch; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.utils.viewport.FitViewport; +import com.badlogic.gdx.utils.viewport.Viewport; +import lombok.Getter; + +@Getter +public class DylGame extends Game { + public static final float WORLD_WIDTH = 10f; + public static final float WORLD_HEIGHT = 10f; + public static final float UNIT_SCALE = 1/10f; + + private InputMultiplexer inputMultiplexer; + private Batch batch; + private OrthographicCamera camera; + private Viewport viewport; + private Settings settings = Settings.builder().build(); + + @Override + public void create() { + inputMultiplexer = new InputMultiplexer(); + Gdx.input.setInputProcessor(inputMultiplexer); + + batch = new SpriteBatch(); + camera = new OrthographicCamera(); + viewport = new FitViewport(WORLD_WIDTH, WORLD_HEIGHT, camera); + + setScreen(new GameScreen(this)); + } + + @Override + public void render() { + Gdx.gl.glClearColor(0, 0, 0, 1); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + + super.render(); + } + + @Override + public void resize(final int width, final int height) { + viewport.update(width, height, true); + super.resize(width, height); + } +} diff --git a/core/src/main/java/coffee/liz/dyl/FirstScreen.java b/core/src/main/java/coffee/liz/dyl/FirstScreen.java deleted file mode 100644 index 4901283..0000000 --- a/core/src/main/java/coffee/liz/dyl/FirstScreen.java +++ /dev/null @@ -1,45 +0,0 @@ -package coffee.liz.dyl; - -import com.badlogic.gdx.Screen; - -/** First screen of the application. Displayed after the application is created. */ -public class FirstScreen implements Screen { - @Override - public void show() { - // Prepare your screen here. - } - - @Override - public void render(float delta) { - // Draw your screen here. "delta" is the time since last render in seconds. - } - - @Override - public void resize(int width, int height) { - // If the window is minimized on a desktop (LWJGL3) platform, width and height are 0, which causes problems. - // In that case, we don't resize anything, and wait for the window to be a normal size before updating. - if(width <= 0 || height <= 0) return; - - // Resize your screen here. The parameters represent the new window size. - } - - @Override - public void pause() { - // Invoked when your application is paused. - } - - @Override - public void resume() { - // Invoked when your application is resumed after pause. - } - - @Override - public void hide() { - // This method is called when another screen replaces this one. - } - - @Override - public void dispose() { - // Destroy screen's assets here. - } -} \ No newline at end of file diff --git a/core/src/main/java/coffee/liz/dyl/FrameState.java b/core/src/main/java/coffee/liz/dyl/FrameState.java new file mode 100644 index 0000000..7d70cde --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/FrameState.java @@ -0,0 +1,16 @@ +package coffee.liz.dyl; + +import coffee.liz.dyl.config.Settings; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.function.Predicate; + +@Builder +@Getter +@RequiredArgsConstructor +public class FrameState { + private final Settings settings; + private final Predicate isKeyPressed; +} diff --git a/core/src/main/java/coffee/liz/dyl/Main.java b/core/src/main/java/coffee/liz/dyl/Main.java deleted file mode 100644 index 24fa3de..0000000 --- a/core/src/main/java/coffee/liz/dyl/Main.java +++ /dev/null @@ -1,11 +0,0 @@ -package coffee.liz.dyl; - -import com.badlogic.gdx.Game; - -/** {@link com.badlogic.gdx.ApplicationListener} implementation shared by all platforms. */ -public class Main extends Game { - @Override - public void create() { - setScreen(new FirstScreen()); - } -} \ No newline at end of file diff --git a/core/src/main/java/coffee/liz/dyl/components/Controllable.java b/core/src/main/java/coffee/liz/dyl/components/Controllable.java new file mode 100644 index 0000000..c476c91 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/components/Controllable.java @@ -0,0 +1,6 @@ +package coffee.liz.dyl.components; + +import coffee.liz.ecs.model.Component; + +public class Controllable implements Component { +} diff --git a/core/src/main/java/coffee/liz/dyl/components/Transform.java b/core/src/main/java/coffee/liz/dyl/components/Transform.java new file mode 100644 index 0000000..b2d5a60 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/components/Transform.java @@ -0,0 +1,18 @@ +package coffee.liz.dyl.components; + +import coffee.liz.ecs.model.Component; +import com.badlogic.gdx.math.Vector2; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class Transform implements Component, Comparable { + private Vector2 position; + private int z; + + @Override + public int compareTo(final Transform other) { + return Integer.compare(z, other.getZ()); + } +} diff --git a/core/src/main/java/coffee/liz/dyl/components/Velocity.java b/core/src/main/java/coffee/liz/dyl/components/Velocity.java new file mode 100644 index 0000000..eb981f7 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/components/Velocity.java @@ -0,0 +1,16 @@ +package coffee.liz.dyl.components; + +import coffee.liz.ecs.model.Component; +import com.badlogic.gdx.math.Vector2; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +public class Velocity implements Component { + private Vector2 velocity = Vector2.Zero; +} diff --git a/core/src/main/java/coffee/liz/dyl/components/graphic/Graphic.java b/core/src/main/java/coffee/liz/dyl/components/graphic/Graphic.java new file mode 100644 index 0000000..648a930 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/components/graphic/Graphic.java @@ -0,0 +1,13 @@ +package coffee.liz.dyl.components.graphic; + +import coffee.liz.dyl.components.Transform; +import coffee.liz.ecs.model.Component; +import com.badlogic.gdx.graphics.g2d.Batch; + +public interface Graphic extends Component { + default Class getKey() { + return Graphic.class; + } + + void draw(final Batch batch, final Transform transform); +} diff --git a/core/src/main/java/coffee/liz/dyl/components/graphic/TextureGraphic.java b/core/src/main/java/coffee/liz/dyl/components/graphic/TextureGraphic.java new file mode 100644 index 0000000..0d12f1a --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/components/graphic/TextureGraphic.java @@ -0,0 +1,21 @@ +package coffee.liz.dyl.components.graphic; + +import coffee.liz.dyl.components.Transform; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.Batch; +import com.badlogic.gdx.graphics.g2d.TextureRegion; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TextureGraphic implements Graphic { + private Color color; + private TextureRegion textureRegion; + + @Override + public void draw(final Batch batch, final Transform transform) { + batch.setColor(color); + batch.draw(textureRegion, transform.getPosition().x, transform.getPosition().y); + } +} diff --git a/core/src/main/java/coffee/liz/dyl/config/KeyBinds.java b/core/src/main/java/coffee/liz/dyl/config/KeyBinds.java new file mode 100644 index 0000000..762dc48 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/config/KeyBinds.java @@ -0,0 +1,41 @@ +package coffee.liz.dyl.config; +import com.badlogic.gdx.Input; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +@Builder +@AllArgsConstructor +@RequiredArgsConstructor +@Getter +public class KeyBinds { + @Builder.Default + private Set moveUpKeys = Set.of(Input.Keys.K, Input.Keys.UP, Input.Keys.W); + @Builder.Default + private Set moveDownKeys = Set.of(Input.Keys.J, Input.Keys.DOWN, Input.Keys.S); + @Builder.Default + private Set moveLeftKeys = Set.of(Input.Keys.H, Input.Keys.LEFT, Input.Keys.A); + @Builder.Default + private Set moveRightKeys = Set.of(Input.Keys.L, Input.Keys.RIGHT, Input.Keys.D); + + public Set filterActiveActions(final Predicate isDown) { + final Set actions = new HashSet<>(); + Map.of(moveUpKeys, Action.MOVE_UP, moveDownKeys, Action.MOVE_DOWN, moveRightKeys, Action.MOVE_RIGHT, + moveLeftKeys, Action.MOVE_LEFT).forEach((keys, action) -> { + if (keys.stream().anyMatch(isDown)) { + actions.add(action); + } + }); + return actions; + } + + public enum Action { + MOVE_UP, MOVE_LEFT, MOVE_DOWN, MOVE_RIGHT; + } +} diff --git a/core/src/main/java/coffee/liz/dyl/config/Settings.java b/core/src/main/java/coffee/liz/dyl/config/Settings.java new file mode 100644 index 0000000..13fbb3c --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/config/Settings.java @@ -0,0 +1,16 @@ +package coffee.liz.dyl.config; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class Settings { + @Builder.Default + private boolean skipIntro = true; + @Builder.Default + private boolean playMusic = true; + @Builder.Default + private KeyBinds keyBinds = KeyBinds.builder().build(); +} diff --git a/core/src/main/java/coffee/liz/dyl/entities/PlayerFactory.java b/core/src/main/java/coffee/liz/dyl/entities/PlayerFactory.java new file mode 100644 index 0000000..f7344c7 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/entities/PlayerFactory.java @@ -0,0 +1,31 @@ +package coffee.liz.dyl.entities; + +import coffee.liz.dyl.components.Controllable; +import coffee.liz.dyl.components.Velocity; +import coffee.liz.dyl.components.graphic.TextureGraphic; +import coffee.liz.ecs.model.Entity; +import coffee.liz.ecs.model.World; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.files.FileHandle; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.TextureRegion; + +public class PlayerFactory { + private static final int PENGUIN_FRAMES_Y = 4; + private static final int PENGUIN_FRAMES_X = 8; + private static final FileHandle FILE = Gdx.files.internal("player.png"); + + public static Entity addTo(final World world) { + final Texture texture = new Texture(FILE); + final TextureRegion[][] tmp = TextureRegion.split(texture, + texture.getWidth() / PENGUIN_FRAMES_X, + texture.getHeight() / PENGUIN_FRAMES_Y); + return world.createEntity() + .add(new TextureGraphic(Color.PINK, tmp[0][0])) + .add(new Controllable()) + .add(new Velocity()); + } + + private PlayerFactory() { } +} diff --git a/core/src/main/java/coffee/liz/dyl/screen/GameScreen.java b/core/src/main/java/coffee/liz/dyl/screen/GameScreen.java new file mode 100644 index 0000000..54dfda3 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/screen/GameScreen.java @@ -0,0 +1,51 @@ +package coffee.liz.dyl.screen; + +import coffee.liz.dyl.DylGame; +import coffee.liz.dyl.FrameState; +import coffee.liz.dyl.world.DylGameWorld; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class GameScreen implements Screen { + private final DylGame game; + + private DylGameWorld dylGameWorld; + private FrameState frameState; + + @Override + public void show() { + dylGameWorld = new DylGameWorld(game); + frameState = FrameState.builder() + .settings(game.getSettings()) + .isKeyPressed(Gdx.input::isKeyPressed) + .build(); + } + + @Override + public void render(final float delta) { + dylGameWorld.update(frameState, delta); + } + + @Override + public void resize(final int width, final int height) { + } + + @Override + public void pause() { + } + + @Override + public void resume() { + } + + @Override + public void hide() { + } + + @Override + public void dispose() { + dylGameWorld.dispose(); + } +} diff --git a/core/src/main/java/coffee/liz/dyl/systems/InputSystem.java b/core/src/main/java/coffee/liz/dyl/systems/InputSystem.java new file mode 100644 index 0000000..8a357ba --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/systems/InputSystem.java @@ -0,0 +1,41 @@ +package coffee.liz.dyl.systems; + +import coffee.liz.dyl.FrameState; +import coffee.liz.dyl.components.Transform; +import coffee.liz.dyl.components.Velocity; +import coffee.liz.dyl.config.KeyBinds; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; +import com.badlogic.gdx.math.Vector2; + +import java.util.Collection; +import java.util.Set; + +public class InputSystem implements System { + @Override + public Collection>> getDependencies() { + return Set.of(); + } + + @Override + public void update(final World world, final FrameState state, final float deltaSeconds) { + final Set currentlyActive = state.getSettings().getKeyBinds() + .filterActiveActions(state.getIsKeyPressed()); + + final Vector2 momentum = Vector2.Zero.cpy(); + currentlyActive.stream().map(InputSystem::actionToMovement).forEach(momentum::add); + + world.queryable().allOf(Velocity.class, Transform.class).forEach(e -> { + e.get(Velocity.class).getVelocity().set(momentum); + }); + } + + private static Vector2 actionToMovement(final KeyBinds.Action action) { + return switch (action) { + case MOVE_UP -> new Vector2(0, 1); + case MOVE_DOWN -> new Vector2(0, -1); + case MOVE_LEFT -> new Vector2(-1, 0); + case MOVE_RIGHT -> new Vector2(1, 0); + }; + } +} diff --git a/core/src/main/java/coffee/liz/dyl/systems/IntegrationSystem.java b/core/src/main/java/coffee/liz/dyl/systems/IntegrationSystem.java new file mode 100644 index 0000000..3fb9b76 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/systems/IntegrationSystem.java @@ -0,0 +1,30 @@ +package coffee.liz.dyl.systems; + +import coffee.liz.dyl.FrameState; +import coffee.liz.dyl.components.Transform; +import coffee.liz.dyl.components.Velocity; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; +import com.badlogic.gdx.math.Vector2; +import lombok.extern.log4j.Log4j2; + +import java.util.Collection; +import java.util.List; + +@Log4j2 +public class IntegrationSystem implements System { + @Override + public Collection>> getDependencies() { + return List.of(InputSystem.class); + } + + @Override + public void update(final World world, final FrameState state, final float deltaSeconds) { + world.queryable().allOf(Velocity.class, Transform.class) + .forEach(e -> { + final Vector2 velocity = e.get(Velocity.class).getVelocity(); + final Vector2 position = e.get(Transform.class).getPosition(); + position.add(velocity.cpy().scl(deltaSeconds)); + }); + } +} diff --git a/core/src/main/java/coffee/liz/dyl/systems/RenderSystem.java b/core/src/main/java/coffee/liz/dyl/systems/RenderSystem.java new file mode 100644 index 0000000..2f185cd --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/systems/RenderSystem.java @@ -0,0 +1,53 @@ +package coffee.liz.dyl.systems; + +import coffee.liz.dyl.FrameState; +import coffee.liz.dyl.components.graphic.Graphic; +import coffee.liz.dyl.components.Transform; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.OrthographicCamera; +import com.badlogic.gdx.graphics.g2d.Batch; +import com.badlogic.gdx.utils.viewport.Viewport; + +import java.util.Collection; +import java.util.Comparator; +import java.util.Set; + +public class RenderSystem implements System { + private final Batch batch; + private final OrthographicCamera camera; + private final Viewport viewport; + + public RenderSystem(final Batch batch, final Viewport viewport) { + this.batch = batch; + this.viewport = viewport; + this.camera = (OrthographicCamera) viewport.getCamera(); + } + + @Override + public Collection>> getDependencies() { + return Set.of(IntegrationSystem.class); + } + + @Override + public void update(final World world, final FrameState state, final float deltaSeconds) { + viewport.apply(); + camera.update(); + + batch.setProjectionMatrix(camera.combined); + batch.begin(); + batch.setColor(Color.WHITE); + + world.queryable().allOf(Transform.class, Graphic.class).stream() + .sorted(Comparator.comparing(e -> e.get(Transform.class))) + .forEach(e -> { + final Transform transform = e.get(Transform.class); + final Graphic graphic = e.get(Graphic.class); + graphic.draw(batch, transform); + }); + + batch.setColor(Color.WHITE); + batch.end(); + } +} diff --git a/core/src/main/java/coffee/liz/dyl/world/DylGameWorld.java b/core/src/main/java/coffee/liz/dyl/world/DylGameWorld.java new file mode 100644 index 0000000..b04a4df --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/world/DylGameWorld.java @@ -0,0 +1,23 @@ +package coffee.liz.dyl.world; + +import coffee.liz.dyl.DylGame; +import coffee.liz.dyl.FrameState; +import coffee.liz.dyl.components.Transform; +import coffee.liz.dyl.entities.PlayerFactory; +import coffee.liz.dyl.systems.InputSystem; +import coffee.liz.dyl.systems.IntegrationSystem; +import coffee.liz.dyl.systems.RenderSystem; +import coffee.liz.ecs.DAGWorld; +import com.badlogic.gdx.math.Vector2; + +public class DylGameWorld extends DAGWorld { + public DylGameWorld(final DylGame game) { + super( + new InputSystem(), + new IntegrationSystem(), + new RenderSystem(game.getBatch(), game.getViewport()) + ); + PlayerFactory.addTo(this) + .add(new Transform(Vector2.One, 1)); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/DAGWorld.java b/core/src/main/java/coffee/liz/ecs/DAGWorld.java new file mode 100644 index 0000000..1075e5e --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/DAGWorld.java @@ -0,0 +1,207 @@ +package coffee.liz.ecs; + +import coffee.liz.ecs.model.Component; +import coffee.liz.ecs.model.Entity; +import coffee.liz.ecs.model.Query; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +/** World that updates in {@link System#getDependencies()} topological order. */ +@Log4j2 +@RequiredArgsConstructor +public class DAGWorld implements World { + /** All entities in the world. */ + protected final Set entities = Collections.synchronizedSet(new HashSet<>()); + + /** Cache mapping component types to entities having that component. */ + private final Map, Set> componentCache = Collections + .synchronizedMap(new HashMap<>()); + + /** Deterministic ID's for spawned entities. */ + private final AtomicInteger nextEntityId = new AtomicInteger(0); + + /** All registered systems. */ + protected final Map>, System> systems; + + /** Ordered list of systems for execution. */ + private final List> systemExecutionOrder; + + @SafeVarargs + public DAGWorld(final System... systems) { + this.systems = singletonClazzMap(systems); + this.systemExecutionOrder = buildExecutionOrder(Arrays.asList(systems)); + log.debug("Executing in order: {}", systemExecutionOrder); + } + + @Override + public Entity createEntity() { + final Entity entity = Entity.builder().id(nextEntityId.incrementAndGet()).build(); + entities.add(entity); + return entity; + } + + @Override + public void removeEntity(final Entity entity) { + entity.getComponentMap().keySet().forEach(componentType -> { + final Set cachedEntities = componentCache.get(componentType); + if (cachedEntities != null) { + cachedEntities.remove(entity); + } + }); + entities.remove(entity); + } + + @Override + public Set resolve(final Query query) { + final Set> components = query.queryingComponents(); + return switch (query.filter()) { + case ALL_OF -> resolveAllOf(components); + case ANY_OF -> resolveAnyOf(components); + case NONE_OF -> resolveNoneOf(components); + }; + } + + @Override + public void update(final T state, final float deltaSeconds) { + systemExecutionOrder.forEach(system -> { + refreshComponentCache(); + system.update(this, state, deltaSeconds); + }); + refreshComponentCache(); + } + + @SuppressWarnings("unchecked") + @Override + public > S getSystem(final Class system) { + return (S) systems.get(system); + } + + private void refreshComponentCache() { + componentCache.clear(); + entities.forEach(entity -> entity.getComponentMap().keySet().forEach( + componentType -> componentCache.computeIfAbsent(componentType, _comp -> new HashSet<>()).add(entity))); + } + + private Set resolveAllOf(final Set> components) { + if (components.isEmpty()) { + return Set.copyOf(entities); + } + + final Class firstType = components.iterator().next(); + final Set candidates = componentCache.get(firstType); + if (candidates == null) { + return Collections.emptySet(); + } + + return candidates.stream().filter(entity -> components.stream().allMatch(entity::has)) + .collect(Collectors.toSet()); + } + + private Set resolveAnyOf(final Set> components) { + if (components.isEmpty()) { + return Collections.emptySet(); + } + + return entities.stream().filter(entity -> components.stream().anyMatch(entity::has)) + .collect(Collectors.toSet()); + } + + private Set resolveNoneOf(final Set> components) { + if (components.isEmpty()) { + return Set.copyOf(entities); + } + + return entities.stream().filter(entity -> components.stream().noneMatch(entity::has)) + .collect(Collectors.toSet()); + } + + private List> buildExecutionOrder(final Collection> systems) { + if (systems.isEmpty()) { + return Collections.emptyList(); + } + + final Map, System> systemMap = systems.stream() + .collect(Collectors.toMap(System::getClass, system -> system, (_sys, b) -> b, LinkedHashMap::new)); + final Map, Integer> inDegree = new LinkedHashMap<>(); + final Map, Set>> adjacencyList = new LinkedHashMap<>(); + + systems.forEach(system -> { + final Class systemClass = system.getClass(); + inDegree.put(systemClass, 0); + adjacencyList.put(systemClass, new HashSet<>()); + }); + + systems.forEach(system -> { + system.getDependencies().forEach(dependency -> { + if (systemMap.containsKey(dependency)) { + adjacencyList.get(dependency).add(system.getClass()); + inDegree.merge(system.getClass(), 1, Integer::sum); + } + }); + }); + + // Kahn's algorithm + final List> result = new ArrayList<>(); + + final Queue> queue = new LinkedList<>( + inDegree.entrySet().stream().filter(entry -> entry.getValue() == 0).map(Map.Entry::getKey).toList()); + + while (!queue.isEmpty()) { + final Class currentClass = queue.poll(); + result.add(systemMap.get(currentClass)); + + adjacencyList.getOrDefault(currentClass, Collections.emptySet()).forEach(dependent -> { + final int newInDegree = inDegree.get(dependent) - 1; + inDegree.put(dependent, newInDegree); + if (newInDegree == 0) { + queue.add(dependent); + } + }); + } + + if (result.size() != systems.size()) { + throw new IllegalStateException("Circular dependency detected in systems"); + } + + return Collections.unmodifiableList(result); + } + + @Override + public void dispose() { + for (final System system : systemExecutionOrder) { + system.dispose(); + } + componentCache.clear(); + entities.clear(); + } + + @SuppressWarnings("unchecked") + private static Map, T> singletonClazzMap(final T... singletons) { + final boolean areSingletons = Arrays.stream(singletons).map(t -> (Class>) t.getClass()) + .distinct().count() == singletons.length; + if (!areSingletons) { + throw new IllegalArgumentException("Only one instance may be used per clazz"); + } + + return Arrays.stream(singletons).map(t -> Map.entry((Class) t.getClass(), t)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/model/Component.java b/core/src/main/java/coffee/liz/ecs/model/Component.java new file mode 100644 index 0000000..2d3a8e7 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/Component.java @@ -0,0 +1,8 @@ +package coffee.liz.ecs.model; + +/** Component of an {@link Entity}. */ +public interface Component { + default Class getKey() { + return getClass(); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/model/Entity.java b/core/src/main/java/coffee/liz/ecs/model/Entity.java new file mode 100644 index 0000000..7dab667 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/Entity.java @@ -0,0 +1,105 @@ +package coffee.liz.ecs.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@Getter +@Builder +@RequiredArgsConstructor +@AllArgsConstructor +@Data +public class Entity { + /** Unique id. */ + private final int id; + + /** Instances of {@link Component}s. */ + @Builder.Default + private Map, Component> componentMap = Collections.synchronizedMap(new HashMap<>()); + + /** + * Check if entity has component type. + * + * @param componentType + * the {@link Component} class + * @return true if component exists + */ + public boolean has(final Class componentType) { + return componentMap.containsKey(componentType); + } + + /** + * Check if entity has all component types. + * + * @param components + * collection of {@link Component} classes + * @return true if all components exist + */ + public boolean hasAll(final Collection> components) { + return components.stream().allMatch(this::has); + } + + /** + * Get component by type. + * + * @param componentType + * the {@link Component} class + * @param + * component type + * @return the component or throw {@link IllegalArgumentException} + */ + @SuppressWarnings("unchecked") + public C get(final Class componentType) { + final C component = (C) componentMap.get(componentType); + if (component == null) { + throw new IllegalArgumentException( + "Entity with id " + getId() + " does not have required component " + componentType.getSimpleName()); + } + return component; + } + + /** + * Add component to entity. + * + * @param component + * the {@link Component} to add + * @param + * component type + * @return this {@link Entity} for chaining + */ + public Entity add(final C component) { + componentMap.put(component.getKey(), component); + return this; + } + + /** + * Remove component from entity. + * + * @param componentType + * the {@link Component} class to remove + * @param + * component type + * @return this {@link Entity} for chaining + */ + public Entity remove(final Class componentType) { + componentMap.remove(componentType); + return this; + } + + /** + * Get all component types. + * + * @return set of {@link Component} classes + */ + public Set> componentTypes() { + return componentMap.keySet(); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/model/Query.java b/core/src/main/java/coffee/liz/ecs/model/Query.java new file mode 100644 index 0000000..679ea49 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/Query.java @@ -0,0 +1,30 @@ +package coffee.liz.ecs.model; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +public record Query(Set> queryingComponents, QueryFilter filter) { + public Query { + queryingComponents = Set.copyOf(queryingComponents); + } + + @SafeVarargs + public static Query allOf(final Class... components) { + return new Query(Arrays.stream(components).collect(Collectors.toSet()), QueryFilter.ALL_OF); + } + + @SafeVarargs + public static Query anyOf(final Class... components) { + return new Query(Arrays.stream(components).collect(Collectors.toSet()), QueryFilter.ANY_OF); + } + + @SafeVarargs + public static Query noneOf(final Class... components) { + return new Query(Arrays.stream(components).collect(Collectors.toSet()), QueryFilter.NONE_OF); + } + + public enum QueryFilter { + ALL_OF, ANY_OF, NONE_OF + } +} diff --git a/core/src/main/java/coffee/liz/ecs/model/QueryBuilder.java b/core/src/main/java/coffee/liz/ecs/model/QueryBuilder.java new file mode 100644 index 0000000..eba5021 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/QueryBuilder.java @@ -0,0 +1,25 @@ +package coffee.liz.ecs.model; + +import lombok.RequiredArgsConstructor; + +import java.util.Set; + +@RequiredArgsConstructor +public class QueryBuilder { + private final World world; + + @SafeVarargs + public final Set allOf(final Class... components) { + return world.resolve(Query.allOf(components)); + } + + @SafeVarargs + public final Set anyOf(final Class... components) { + return world.resolve(Query.anyOf(components)); + } + + @SafeVarargs + public final Set noneOf(final Class... components) { + return world.resolve(Query.noneOf(components)); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/model/System.java b/core/src/main/java/coffee/liz/ecs/model/System.java new file mode 100644 index 0000000..c00334a --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/System.java @@ -0,0 +1,32 @@ +package coffee.liz.ecs.model; + +import java.time.Duration; +import java.util.Collection; + +/** + * Updates the {@link World} state. + * + * @param + * is the state of the stuff outside the {@link World}. + */ +public interface System { + /** + * {@link System} clazzes that must run before this system. + * + * @return {@link Collection} of dependencies. + */ + Collection>> getDependencies(); + + /** + * @param world + * Is the {@link World}. + * @param state + * Is the {@link T} state outside the {@param world}. + * @param deltaSeconds + * Is the timestamp. + */ + void update(final World world, final T state, final float deltaSeconds); + + default void dispose() { + } +} diff --git a/core/src/main/java/coffee/liz/ecs/model/World.java b/core/src/main/java/coffee/liz/ecs/model/World.java new file mode 100644 index 0000000..82e01a7 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/World.java @@ -0,0 +1,64 @@ +package coffee.liz.ecs.model; + +import java.time.Duration; +import java.util.Set; + +/** + * The game world. + * + * @param + * is the state of the stuff outside the world. + */ +public interface World { + /** + * Create unique {@link Entity} in the {@link World}. + * + * @return created {@link Entity}. + */ + Entity createEntity(); + + /** + * Remove an entity from the {@link World}. + * + * @param entity + * to remove. + */ + void removeEntity(final Entity entity); + + /** + * Get entities with {@link Component}s. + * + * @param query + * to query. + * @return All entities satisfying {@param query}. + */ + Set resolve(final Query query); + + default QueryBuilder queryable() { + return new QueryBuilder<>(this); + } + + /** + * Integrate the {@link World}. + * + * @param state + * Is the state outside the world. + * @param deltaSeconds + * Is the time step. + */ + void update(T state, float deltaSeconds); + + /** + * Get world {@link System}. + * + * @param system + * is the Clazz. + * @param + * is the {@link System} type. + * @return {@link System} instance of {@param system}. + */ + > S getSystem(final Class system); + + default void dispose() { + } +} diff --git a/core/src/main/java/coffee/liz/ecs/utils/FunctionUtils.java b/core/src/main/java/coffee/liz/ecs/utils/FunctionUtils.java new file mode 100644 index 0000000..25e81f0 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/utils/FunctionUtils.java @@ -0,0 +1,28 @@ +package coffee.liz.ecs.utils; + + +public final class FunctionUtils { + /** + * Runs the provided action and wraps failures into a {@link RuntimeException}. + * + * @param run + * action to execute + */ + public static void wrapCheckedException(final ThrowableRunnable run) { + try { + run.run(); + } catch (final Throwable e) { + throw new RuntimeException(e); + } + } + + @FunctionalInterface + public interface ThrowableRunnable { + /** + * Run. + */ + void run() throws E; + } + + private FunctionUtils() { } +} diff --git a/core/src/test/java/coffee/liz/ecs/DAGWorldTest.java b/core/src/test/java/coffee/liz/ecs/DAGWorldTest.java new file mode 100644 index 0000000..cf6cdad --- /dev/null +++ b/core/src/test/java/coffee/liz/ecs/DAGWorldTest.java @@ -0,0 +1,206 @@ +package coffee.liz.ecs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import coffee.liz.ecs.model.Component; +import coffee.liz.ecs.model.Entity; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; +import coffee.liz.ecs.model.Query; + +import lombok.RequiredArgsConstructor; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class DAGWorldTest { + @ParameterizedTest + @MethodSource("queryScenarios") + public void queryResolvesExpectedEntities(final Query query, final Set expectedEntityKeys) { + final World world = new DAGWorld<>(); + final Entity both = world.createEntity(); + both.add(new PositionComponent()); + both.add(new VelocityComponent()); + final Entity positionOnly = world.createEntity(); + positionOnly.add(new PositionComponent()); + final Entity velocityOnly = world.createEntity(); + velocityOnly.add(new VelocityComponent()); + final Entity neither = world.createEntity(); + + world.update("state", 0); + + final Map entities = Map.of("both", both, "positionOnly", positionOnly, "velocityOnly", + velocityOnly, "neither", neither); + final Set expectedEntities = expectedEntityKeys.stream().map(entities::get).collect(Collectors.toSet()); + assertEquals(expectedEntities, world.resolve(query)); + } + + private static Stream queryScenarios() { + return Stream.of(Arguments.of(Query.allOf(PositionComponent.class, VelocityComponent.class), Set.of("both")), + Arguments.of(Query.allOf(), Set.of("both", "positionOnly", "velocityOnly", "neither")), + Arguments.of(Query.anyOf(PositionComponent.class, VelocityComponent.class), + Set.of("both", "positionOnly", "velocityOnly")), + Arguments.of(Query.noneOf(PositionComponent.class, VelocityComponent.class), Set.of("neither"))); + } + + @Test + public void updateExecutesSystemsInTopologicalOrder() { + final CopyOnWriteArrayList executionLog = new CopyOnWriteArrayList<>(); + + final DAGWorld world = new DAGWorld<>(new SystemC(executionLog), new SystemA(executionLog), + new SystemB(executionLog)); + world.update("state", 0); + + assertEquals(List.of("A", "B", "C"), executionLog); + } + + @Test + public void updateRefreshesComponentCacheAfterEntityMutations() { + final DAGWorld world = new DAGWorld<>(); + final Entity subject = world.createEntity(); + + world.update("state", 0); + assertTrue(world.resolve(Query.allOf(PositionComponent.class)).isEmpty()); + + subject.add(new PositionComponent()); + world.update("state", 0); + assertEquals(1, world.resolve(Query.allOf(PositionComponent.class)).size()); + + subject.remove(PositionComponent.class); + world.update("state", 0); + assertTrue(world.resolve(Query.allOf(PositionComponent.class)).isEmpty()); + } + + @Test + public void queryFindsComponentsAddedByEarlierSystemInSameTick() { + final List> queryResults = new CopyOnWriteArrayList<>(); + + final DAGWorld world = new DAGWorld<>(new ComponentAdderSystem(), + new ComponentReaderSystem(queryResults)); + + final Entity entity = world.createEntity(); + entity.add(new PositionComponent()); + + world.update("state", 0); + + assertEquals(1, queryResults.size()); + assertEquals(Set.of(entity), queryResults.get(0)); + } + + @Test + public void circularDependencyDetectionThrowsIllegalStateException() { + assertThrows(IllegalStateException.class, () -> new DAGWorld<>(new SystemCycleA(), new SystemCycleB())); + } + + private static final class PositionComponent implements Component { + } + + private static final class VelocityComponent implements Component { + } + + @RequiredArgsConstructor + private abstract static class RecordingSystem implements System { + private final List log; + private final String label; + + @Override + public final void update(final World world, final String state, final float deltaSeconds) { + log.add(label); + } + } + + private static final class SystemA extends RecordingSystem { + private SystemA(final List log) { + super(log, "A"); + } + + @Override + public Collection>> getDependencies() { + return List.of(); + } + } + + private static final class SystemB extends RecordingSystem { + private SystemB(final List log) { + super(log, "B"); + } + + @Override + public Collection>> getDependencies() { + return Set.of(SystemA.class); + } + } + + private static final class SystemC extends RecordingSystem { + private SystemC(final List log) { + super(log, "C"); + } + + @Override + public Collection>> getDependencies() { + return Set.of(SystemB.class); + } + } + + private static final class SystemCycleA implements System { + @Override + public Collection>> getDependencies() { + return Set.of(SystemCycleB.class); + } + + @Override + public final void update(final World world, final String state, final float deltaSeconds) { + } + } + + private static final class SystemCycleB implements System { + @Override + public Collection>> getDependencies() { + return Set.of(SystemCycleA.class); + } + + @Override + public final void update(final World world, final String state, final float deltaSeconds) { + } + } + + private static final class ComponentAdderSystem implements System { + @Override + public Collection>> getDependencies() { + return List.of(); + } + + @Override + public final void update(final World world, final String state, final float deltaSeconds) { + world.resolve(Query.allOf(PositionComponent.class)).forEach(e -> e.add(new VelocityComponent())); + } + } + + @RequiredArgsConstructor + private static final class ComponentReaderSystem implements System { + private final List> queryResults; + + @Override + public Collection>> getDependencies() { + return Set.of(ComponentAdderSystem.class); + } + + @Override + public final void update(final World world, final String state, final float deltaSeconds) { + queryResults.add(world.resolve(Query.allOf(VelocityComponent.class, PositionComponent.class))); + } + } +} diff --git a/core/src/test/java/coffee/liz/ecs/model/EntityTest.java b/core/src/test/java/coffee/liz/ecs/model/EntityTest.java new file mode 100644 index 0000000..a8fd1e3 --- /dev/null +++ b/core/src/test/java/coffee/liz/ecs/model/EntityTest.java @@ -0,0 +1,97 @@ +package coffee.liz.ecs.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import lombok.RequiredArgsConstructor; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; + +final class EntityTest { + @ParameterizedTest + @MethodSource("componentCombinationProvider") + public void hasAllReportsPresenceForComponentSets(final Collection> query, + final boolean expected) { + final Entity entity = Entity.builder().id(7).build(); + entity.add(new AlphaComponent("first")); + entity.add(new BetaComponent(3)); + entity.add(new GammaKeyedComponent()); + + assertEquals(expected, entity.hasAll(query)); + } + + private static Stream componentCombinationProvider() { + return Stream + .of(Arguments.of(List.of(AlphaComponent.class), true), Arguments.of(List.of(BetaComponent.class), true), + Arguments.of(List.of(AlphaComponent.class, BetaComponent.class, GammaComponent.class), true), + Arguments.of(List.of(AlphaComponent.class, BetaComponent.class, GammaKeyedComponent.class), + false), + Arguments.of(List.of(GammaComponent.class), true), + Arguments.of(List.of(GammaKeyedComponent.class), false)); + } + + @Test + public void getThrowsForMissingComponentsWithHelpfulMessage() { + final Entity entity = Entity.builder().id(99).build(); + + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> entity.get(AlphaComponent.class)); + + assertTrue(thrown.getMessage().contains("AlphaComponent")); + assertTrue(thrown.getMessage().contains("99")); + } + + @Test + public void addReplacesExistingComponentInstance() { + final Entity entity = Entity.builder().id(17).build(); + final AlphaComponent initial = new AlphaComponent("initial"); + entity.add(initial); + + final AlphaComponent replacement = new AlphaComponent("replacement"); + entity.add(replacement); + + assertSame(replacement, entity.get(AlphaComponent.class)); + } + + @Test + public void removeClearsComponentPresence() { + final Entity entity = Entity.builder().id(45).build(); + entity.add(new BetaComponent(2)); + assertTrue(entity.has(BetaComponent.class)); + + entity.remove(BetaComponent.class); + + assertFalse(entity.has(BetaComponent.class)); + assertTrue(entity.componentTypes().isEmpty()); + } + + private record AlphaComponent(String name) implements Component { + } + + private record BetaComponent(int level) implements Component { + } + + @RequiredArgsConstructor + private class GammaComponent implements Component { + @Override + public Class getKey() { + return GammaComponent.class; + } + } + + private class GammaKeyedComponent extends GammaComponent { + } + + private record ContextualComponent(int ownerId) implements Component { + } +} diff --git a/design/design.txt b/design/design.txt new file mode 100644 index 0000000..3cf671b --- /dev/null +++ b/design/design.txt @@ -0,0 +1,53 @@ +=== +Don't You Leave (really draft) +=== + +This is a game inspired by A Dark Room. The game is (probably) a crafting, RTS-inspired, roguelike. A run is ~50 minutes. + +Your main goal is to keep a fire, whose fuel is cash, warm for your wife. As the fire grows in size, other civilians start to base around the flame. These do nothing but stay next to your fire and run away if they get too cold, but you can click on them to "assign" them a job. + +Your fire increasingly needs more fuel as it grows in size. To gain more money you need to kill more and more players / pillage civilizations. You're forced to continue coming back to the fire by the fact that you need to refill the storage of fuel to store for the fire. + +Other civilizations' civilians are parts of armies. But in your civilization, all civilians are "pacifists". They die if they die; they don't fight back. It is fully on you to take entire armies - those attacking and which you attack. As you kill more and more others, your wife becomes increasingly more and more dissatisfied. + +There are two ways to lose: +- You die +- Your wife dies. You kill yourself. Loss text: "You couldn't protect her. Try again?" + +Other "players" are constantly coming to your civilization to kill your wife. + +The main notification system of the game is the edges of the screen and your wife's heartbeat. It gets slower. +- Text in a marquee style "chilly" -> "cold" -> "icy" -> "hypothermic", as ice increasingly becomes opaque, takes over the edges of the screen. +- Depending on the distance to the nearest enemy, "uncomfortable" -> "scared" -> "fear" -> "terror" with purple edges. + +The game ends when all other civiliazations. At that point, all your civilians will battle against you. Once you kill all of them, your wife becomes the final boss, and it's the hardest. When you kill her, you get infinite money but "You couldn't protect her. Try again?" but instead of a sad ending it's happy because you made so much money! + +===Crafting=== + +There are resources which you need to collect to make weapons. Cash is the base resource, which is the fuel which goes to your fire that is piped into factories around the fire and stuff. + +- Wood (for sticks) +- Magic emerald (for staff) +- Iron (for swords, +- Oil (for your car) + +===Exploration=== + +You need to monitor your hunger and thirst when traveling. There are wells, abandoned farms, (tbd) oracles to visit and include into your inventory. + +Depending on the radius to your little settlement is how advanced the civilization is; mostly just due to the fact they've had time to develop (THIS WILL REQUIRE A GOOD BIT OF ARTIFICIAL INTELLIGENCE TO CRAFT I BELIEVE). + +You start with just a small pack to bring back things to craft with. But you can craft larger things to bring back larger / more items as you explore. This is justified by items' weight. + +===Combat=== + +You start from just your fists. (TODO) You can pick up a rock and punch down a tree. + +There are a few main "classes" to spec into. +Magic +Military +Science + +Strategy manifests in maintaining the order of upgrades you choose to stay ahead of the threat. For instance, a distant civilization that is really really advanced in science might take less precidence over a medium advanced military civilization close by attacking soon. + +The Oracles are key to your combat experiences. There are a few oracles. It marks on your map the positions of civilizations and when they plan on attacking. But it doesn't reveal what kind of civilization (magic, military, science, etc.) it is. Thus, you need to go scout the attackers to see what you need to plan / gather for. diff --git a/lwjgl3/src/main/java/coffee/liz/dyl/lwjgl3/Lwjgl3Launcher.java b/lwjgl3/src/main/java/coffee/liz/dyl/lwjgl3/Lwjgl3Launcher.java index a8c9c4f..1902322 100644 --- a/lwjgl3/src/main/java/coffee/liz/dyl/lwjgl3/Lwjgl3Launcher.java +++ b/lwjgl3/src/main/java/coffee/liz/dyl/lwjgl3/Lwjgl3Launcher.java @@ -2,7 +2,7 @@ package coffee.liz.dyl.lwjgl3; import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application; import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration; -import coffee.liz.dyl.Main; +import coffee.liz.dyl.DylGame; /** Launches the desktop (LWJGL3) application. */ public class Lwjgl3Launcher { @@ -12,12 +12,12 @@ public class Lwjgl3Launcher { } private static Lwjgl3Application createApplication() { - return new Lwjgl3Application(new Main(), getDefaultConfiguration()); + return new Lwjgl3Application(new DylGame(), getDefaultConfiguration()); } private static Lwjgl3ApplicationConfiguration getDefaultConfiguration() { Lwjgl3ApplicationConfiguration configuration = new Lwjgl3ApplicationConfiguration(); - configuration.setTitle("dyl"); + configuration.setTitle("Don't You Leave"); //// Vsync limits the frames per second to what your hardware can display, and helps eliminate //// screen tearing. This setting doesn't always work on Linux, so the line after is a safeguard. configuration.useVsync(true); @@ -45,4 +45,4 @@ public class Lwjgl3Launcher { return configuration; } -} \ No newline at end of file +} -- cgit v1.2.3-70-g09d2