aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.editorconfig18
-rw-r--r--.gitattributes2
-rw-r--r--.gitignore164
-rw-r--r--README.md33
-rw-r--r--assets/elf.pngbin0 -> 3849 bytes
-rw-r--r--assets/fonts/JetBrainsMonoNerdFont-Regular.ttfbin0 -> 2469104 bytes
-rw-r--r--build.gradle110
-rw-r--r--core/build.gradle44
-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
-rw-r--r--core/src/main/java/coffee/liz/ecs/DAGWorld.java158
-rw-r--r--core/src/main/java/coffee/liz/ecs/math/Mat2.java91
-rw-r--r--core/src/main/java/coffee/liz/ecs/math/Vec2.java65
-rw-r--r--core/src/main/java/coffee/liz/ecs/math/Vec2f.java54
-rw-r--r--core/src/main/java/coffee/liz/ecs/math/Vec2i.java57
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/Component.java8
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/Entity.java105
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/System.java29
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/World.java58
-rw-r--r--core/src/main/java/coffee/liz/lambda/LambdaDriver.java79
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/Expression.java28
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java19
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/Macro.java12
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/SourceCode.java27
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/SourceComment.java21
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java24
-rw-r--r--core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java23
-rw-r--r--core/src/main/java/coffee/liz/lambda/bind/Tick.java22
-rw-r--r--core/src/main/java/coffee/liz/lambda/bind/ToChurch.java49
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/Environment.java109
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java16
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java101
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/Thunk.java27
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/Value.java42
-rw-r--r--core/src/main/java/coffee/liz/lambda/format/Formatter.java238
-rw-r--r--core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt276
-rw-r--r--core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt287
-rw-r--r--core/src/main/resources/log4j2.xml13
-rw-r--r--core/src/test/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystemTest.java188
-rw-r--r--core/src/test/java/coffee/liz/abstractionengine/grid/system/GridIndexSystemTest.java70
-rw-r--r--core/src/test/java/coffee/liz/abstractionengine/grid/system/GridMovementSystemTest.java48
-rw-r--r--core/src/test/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystemTest.java46
-rw-r--r--core/src/test/java/coffee/liz/ecs/DAGWorldTest.java164
-rw-r--r--core/src/test/java/coffee/liz/ecs/model/EntityTest.java97
-rw-r--r--core/src/test/java/coffee/liz/lambda/eval/InterpreterTest.java103
-rw-r--r--core/src/test/java/coffee/liz/lambda/eval/ThunkTest.java38
-rw-r--r--core/src/test/java/coffee/liz/lambda/format/FormatterTest.java53
-rw-r--r--core/src/test/java/coffee/liz/lambda/parser/ParserTest.java178
-rw-r--r--gradle.properties20
-rw-r--r--gradle/gradle-daemon-jvm.properties12
-rw-r--r--gradle/wrapper/gradle-wrapper.jarbin0 -> 45633 bytes
-rw-r--r--gradle/wrapper/gradle-wrapper.properties7
-rwxr-xr-xgradlew248
-rwxr-xr-xgradlew.bat93
-rw-r--r--lwjgl3/build.gradle193
-rw-r--r--lwjgl3/icons/logo.icnsbin0 -> 201876 bytes
-rw-r--r--lwjgl3/icons/logo.icobin0 -> 4286 bytes
-rw-r--r--lwjgl3/icons/logo.pngbin0 -> 9545 bytes
-rw-r--r--lwjgl3/nativeimage.gradle54
-rw-r--r--lwjgl3/src/main/java/coffee/liz/abstractionengine/lwjgl3/Lwjgl3Launcher.java63
-rw-r--r--lwjgl3/src/main/java/coffee/liz/abstractionengine/lwjgl3/StartupHelper.java218
-rw-r--r--lwjgl3/src/main/resources/libgdx128.pngbin0 -> 9545 bytes
-rw-r--r--lwjgl3/src/main/resources/libgdx16.pngbin0 -> 806 bytes
-rw-r--r--lwjgl3/src/main/resources/libgdx32.pngbin0 -> 2071 bytes
-rw-r--r--lwjgl3/src/main/resources/libgdx64.pngbin0 -> 4929 bytes
-rw-r--r--settings.gradle8
84 files changed, 5207 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..67df35c
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+# https://editorconfig.org
+root = true
+
+[*]
+indent_style = space
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.{java,scala,groovy,kt,kts}]
+indent_size = 4
+
+[*.gradle]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6c84be0
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+* text=auto eol=lf
+*.bat text=auto eol=crlf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bf713c6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,164 @@
+## Gradle:
+.gradle/
+gradle-app.setting
+/build/
+/android/build/
+/core/build/
+/lwjgl2/build/
+/lwjgl3/build/
+/html/build/
+/teavm/build/
+/ios/build/
+/ios-moe/build/
+/headless/build/
+/server/build/
+/shared/build/
+
+## Java:
+*.class
+*.war
+*.ear
+hs_err_pid*
+.attach_pid*
+
+## Android:
+/android/libs/armeabi-v7a/
+/android/libs/arm64-v8a/
+/android/libs/x86/
+/android/libs/x86_64/
+/android/gen/
+/android/out/
+local.properties
+com_crashlytics_export_strings.xml
+
+## Robovm:
+/ios/robovm-build/
+
+## iOS:
+/ios/xcode/*.xcodeproj/*
+!/ios/xcode/*.xcodeproj/xcshareddata
+!/ios/xcode/*.xcodeproj/project.pbxproj
+/ios/xcode/native/
+/ios/IOSLauncher.app
+/ios/IOSLauncher.app.dSYM
+
+## GWT:
+/html/war/
+/html/gwt-unitCache/
+.apt_generated/
+/html/war/WEB-INF/deploy/
+/html/war/WEB-INF/classes/
+.gwt/
+gwt-unitCache/
+www-test/
+.gwt-tmp/
+
+## TeaVM:
+# Not sure yet...
+
+## IntelliJ, Android Studio:
+.idea/
+*.ipr
+*.iws
+*.iml
+
+## Eclipse:
+.classpath
+.project
+.metadata/
+/android/bin/
+/core/bin/
+/lwjgl2/bin/
+/lwjgl3/bin/
+/html/bin/
+/teavm/bin/
+/ios/bin/
+/ios-moe/bin/
+/headless/bin/
+/server/bin/
+/shared/bin/
+*.tmp
+*.bak
+*.swp
+*~.nib
+.settings/
+.loadpath
+.externalToolBuilders/
+*.launch
+
+
+## NetBeans:
+
+/nbproject/private/
+/android/nbproject/private/
+/core/nbproject/private/
+/lwjgl2/nbproject/private/
+/lwjgl3/nbproject/private/
+/html/nbproject/private/
+/teavm/nbproject/private/
+/ios/nbproject/private/
+/ios-moe/nbproject/private/
+/headless/nbproject/private/
+/server/nbproject/private/
+/shared/nbproject/private/
+
+/nbbuild/
+/android/nbbuild/
+/core/nbbuild/
+/lwjgl2/nbbuild/
+/lwjgl3/nbbuild/
+/html/nbbuild/
+/teavm/nbbuild/
+/ios/nbbuild/
+/ios-moe/nbbuild/
+/headless/nbbuild/
+/server/nbbuild/
+/shared/nbbuild/
+
+/dist/
+/android/dist/
+/core/dist/
+/lwjgl2/dist/
+/lwjgl3/dist/
+/html/dist/
+/teavm/dist/
+/ios/dist/
+/ios-moe/dist/
+/headless/dist/
+/server/dist/
+/shared/dist/
+
+/nbdist/
+/android/nbdist/
+/core/nbdist/
+/lwjgl2/nbdist/
+/lwjgl3/nbdist/
+/html/nbdist/
+/teavm/nbdist/
+/ios/nbdist/
+/ios-moe/nbdist/
+/headless/nbdist/
+/server/nbdist/
+/shared/nbdist/
+
+nbactions.xml
+nb-configuration.xml
+
+## OS-Specific:
+.DS_Store
+Thumbs.db
+
+## Miscellaneous:
+*~
+*.*#
+*#*#
+/.kotlin/
+/assets/assets.txt
+
+## Special cases:
+
+## There is a resource-config.json file generated by nativeimage.gradle if you use Graal Native Image.
+## Some usage may need extra resource configuration in a different file with the same name.
+## You could also add that configuration to the text in nativeimage.gradle .
+## You should delete or comment out the next line if you have configuration in a different resource-config.json .
+**/resource-config.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e8f3321
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# The Abstraction Engine
+
+A [libGDX](https://libgdx.com/) project generated with [gdx-liftoff](https://github.com/libgdx/gdx-liftoff).
+
+This project was generated with a template including simple application launchers and an `ApplicationAdapter` extension that draws libGDX logo.
+
+## Platforms
+
+- `core`: Main module with the application logic shared by all platforms.
+- `lwjgl3`: Primary desktop platform using LWJGL3; was called 'desktop' in older docs.
+
+## Gradle
+
+This project uses [Gradle](https://gradle.org/) to manage dependencies.
+The Gradle wrapper was included, so you can run Gradle tasks using `gradlew.bat` or `./gradlew` commands.
+Useful Gradle tasks and flags:
+
+- `--continue`: when using this flag, errors will not stop the tasks from running.
+- `--daemon`: thanks to this flag, Gradle daemon will be used to run chosen tasks.
+- `--offline`: when using this flag, cached dependency archives will be used.
+- `--refresh-dependencies`: this flag forces validation of all dependencies. Useful for snapshot versions.
+- `build`: builds sources and archives of every project.
+- `cleanEclipse`: removes Eclipse project data.
+- `cleanIdea`: removes IntelliJ project data.
+- `clean`: removes `build` folders, which store compiled classes and built archives.
+- `eclipse`: generates Eclipse project data.
+- `idea`: generates IntelliJ project data.
+- `lwjgl3:jar`: builds application's runnable jar, which can be found at `lwjgl3/build/libs`.
+- `lwjgl3:run`: starts the application.
+- `test`: runs unit tests (if any).
+
+Note that most tasks that are not specific to a single project can be run with `name:` prefix, where the `name` should be replaced with the ID of a specific project.
+For example, `core:clean` removes `build` folder only from the `core` project.
diff --git a/assets/elf.png b/assets/elf.png
new file mode 100644
index 0000000..699603a
--- /dev/null
+++ b/assets/elf.png
Binary files differ
diff --git a/assets/fonts/JetBrainsMonoNerdFont-Regular.ttf b/assets/fonts/JetBrainsMonoNerdFont-Regular.ttf
new file mode 100644
index 0000000..235a07a
--- /dev/null
+++ b/assets/fonts/JetBrainsMonoNerdFont-Regular.ttf
Binary files differ
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..98fe629
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,110 @@
+buildscript {
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ mavenLocal()
+ google()
+ maven { url = 'https://central.sonatype.com/repository/maven-snapshots/' }
+ }
+ dependencies {
+ }
+}
+
+plugins {
+ id 'com.diffplug.spotless' version '6.25.0' apply false
+}
+
+allprojects {
+ apply plugin: 'eclipse'
+ apply plugin: 'idea'
+
+ // This allows you to "Build and run using IntelliJ IDEA", an option in IDEA's Settings.
+ idea {
+ module {
+ outputDir = file('build/classes/java/main')
+ testOutputDir = file('build/classes/java/test')
+ }
+ }
+}
+
+configure(subprojects) {
+ apply plugin: 'java-library'
+ apply plugin: 'com.diffplug.spotless'
+
+ java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(24)
+ }
+ }
+ java.sourceCompatibility = 24
+
+ // Spotless code formatting
+ spotless {
+ java {
+ eclipse()
+ target '**/src/**/*.java'
+ }
+ }
+
+ // Lombok configuration
+ 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'
+ }
+
+ test {
+ testLogging {
+ events 'passed', 'skipped', 'failed'
+ }
+ useJUnitPlatform()
+ jvmArgs '-Dnet.bytebuddy.experimental=true'
+ }
+
+ // 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') {
+ inputs.dir("${project.rootDir}/assets/")
+ // projectFolder/assets
+ File assetsFolder = new File("${project.rootDir}/assets/")
+ // projectFolder/assets/assets.txt
+ File assetsFile = new File(assetsFolder, "assets.txt")
+ // delete that file in case we've already created it
+ assetsFile.delete()
+
+ // iterate through all files inside that folder
+ // convert it to a relative path
+ // and append it to the file assets.txt
+ fileTree(assetsFolder).collect { assetsFolder.relativePath(it) }.sort().each {
+ assetsFile.append(it + "\n")
+ }
+ }
+ processResources.dependsOn 'generateAssetList'
+
+ compileJava {
+ options.incremental = true
+ }
+}
+
+subprojects {
+ version = "$projectVersion"
+ ext.appName = 'The Abstraction Engine'
+ repositories {
+ mavenCentral()
+ // You may want to remove the following line if you have errors downloading dependencies.
+ mavenLocal()
+ maven { url = 'https://central.sonatype.com/repository/maven-snapshots/' }
+ maven { url = 'https://jitpack.io' }
+ }
+}
+
+eclipse.project.name = 'The Abstraction Engine' + '-parent'
diff --git a/core/build.gradle b/core/build.gradle
new file mode 100644
index 0000000..0743329
--- /dev/null
+++ b/core/build.gradle
@@ -0,0 +1,44 @@
+plugins {
+ id 'org.javacc.javacc' version '4.0.3'
+}
+
+[compileJava, compileTestJava]*.options*.encoding = 'UTF-8'
+eclipse.project.name = appName + '-core'
+
+// JavaCC/JJTree configuration
+def jjtreeOutputDir = layout.buildDirectory.dir('generated/sources/jjtree')
+def javaccOutputDir = layout.buildDirectory.dir('generated/sources/javacc')
+def javaccPackageDir = layout.buildDirectory.dir('generated/sources/javacc/coffee/liz/lambda/parser')
+
+sourceSets {
+ main {
+ java {
+ srcDirs javaccOutputDir
+ }
+ }
+}
+
+compileJjtree {
+ inputDirectory = file('src/main/java/coffee/liz/lambda/parser')
+ outputDirectory = jjtreeOutputDir.get().asFile
+}
+
+compileJavacc {
+ outputDirectory = javaccPackageDir.get().asFile
+ dependsOn compileJjtree
+}
+
+dependencies {
+ api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion"
+ api "com.badlogicgames.gdx:gdx:$gdxVersion"
+ api "com.kotcrab.vis:vis-ui:$visUiVersion"
+
+ // Log4j logging
+ 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/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));
+ });
+ }
+}
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..716808a
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/DAGWorld.java
@@ -0,0 +1,158 @@
+package coffee.liz.ecs;
+
+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 lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+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<T> implements World<T> {
+ /** All entities in the world. */
+ protected final Set<Entity> entities = Collections.synchronizedSet(new HashSet<>());
+
+ /** Cache mapping component types to entities having that component. */
+ private final Map<Class<? extends Component>, Set<Entity>> componentCache = Collections
+ .synchronizedMap(new HashMap<>());
+
+ /** Deterministic ID's for spawned entities. */
+ private final AtomicInteger nextEntityId = new AtomicInteger(0);
+
+ /** All registered systems. */
+ protected final Map<Class<? extends System<T>>, System<T>> systems;
+
+ /** Ordered list of systems for execution. */
+ private final List<System<T>> systemExecutionOrder;
+
+ public DAGWorld(final Map<Class<? extends System<T>>, System<T>> systems) {
+ this.systems = systems;
+ this.systemExecutionOrder = buildExecutionOrder(systems.values().stream().toList());
+ 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<Entity> cachedEntities = componentCache.get(componentType);
+ if (cachedEntities != null) {
+ cachedEntities.remove(entity);
+ }
+ });
+ entities.remove(entity);
+ }
+
+ @Override
+ public Set<Entity> query(final Collection<Class<? extends Component>> components) {
+ if (components.isEmpty()) {
+ return Set.copyOf(entities);
+ }
+
+ final Class<? extends Component> firstType = components.iterator().next();
+ final Set<Entity> candidates = componentCache.get(firstType);
+ if (candidates == null) {
+ return Collections.emptySet();
+ }
+
+ return candidates.stream().filter(entity -> components.stream().allMatch(entity::has))
+ .collect(Collectors.toSet());
+ }
+
+ @Override
+ public void update(final T state, final Duration duration) {
+ refreshComponentCache();
+ systemExecutionOrder.forEach(system -> {
+ system.update(this, state, duration);
+ });
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public <S extends System<T>> S getSystem(final Class<S> system) {
+ return (S) systems.get(system);
+ }
+
+ private void refreshComponentCache() {
+ componentCache.clear();
+ entities.forEach(entity -> {
+ entity.getComponentMap().keySet().forEach(componentType -> {
+ componentCache.computeIfAbsent(componentType, k -> new HashSet<>()).add(entity);
+ });
+ });
+ }
+
+ private List<System<T>> buildExecutionOrder(final Collection<System<T>> systems) {
+ if (systems.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ final Map<Class<?>, System<T>> systemMap = systems.stream()
+ .collect(Collectors.toMap(System::getClass, system -> system));
+ final Map<Class<?>, Integer> inDegree = new HashMap<>();
+ final Map<Class<?>, Set<Class<?>>> adjacencyList = new HashMap<>();
+
+ 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<System<T>> result = new ArrayList<>();
+
+ final Queue<Class<?>> 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);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/math/Mat2.java b/core/src/main/java/coffee/liz/ecs/math/Mat2.java
new file mode 100644
index 0000000..8be945c
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/math/Mat2.java
@@ -0,0 +1,91 @@
+package coffee.liz.ecs.math;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+public final class Mat2 {
+ private Mat2() {
+ }
+
+ /**
+ * Initializes a mutable 2d matrix of given type.
+ *
+ * @param dimensions
+ * the dimensions
+ * @param constructor
+ * the constructor
+ * @return row-indexed 2d matrix of {@param dimensions}
+ */
+ public static <T> List<List<T>> init(final Vec2<Integer> dimensions, final Function<Vec2<Integer>, T> constructor) {
+ final List<List<T>> rows = new ArrayList<>();
+ for (int y = 0; y < dimensions.getY(); y++) {
+ final List<T> row = new ArrayList<>(dimensions.getX());
+ for (int x = 0; x < dimensions.getX(); x++)
+ row.add(constructor.apply(Vec2i.builder().y(y).x(x).build()));
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ /**
+ * Convolves a {@link Convolver} across a matrix.
+ *
+ * @param mat
+ * is the row-indexed 2d matrix to convolve.
+ * @param axes
+ * are the x/y major/minor axes.
+ * @param init
+ * is the initial value of the convolution.
+ * @param convolver
+ * is the {@link Convolver}.
+ * @param finalReduction
+ * to apply after {@param convolver}.
+ * @return result of {@param convolver} applied along axes at each cell.
+ * @param <T>
+ * is the type of the matrix to convolve.
+ * @param <R>
+ * is the type of the resulting type of each convolution.
+ */
+ public static <T, R, U> List<List<U>> convolve(final List<List<T>> mat, final Vec2<Integer> axes,
+ final Supplier<R> init, final Convolver<T, R> convolver, final BiFunction<T, R, U> finalReduction) {
+ final List<List<R>> rows = new ArrayList<>();
+ for (int y = 0; y < mat.size(); y++) {
+ final List<R> row = new ArrayList<>(mat.get(y).size());
+ for (int x = 0; x < mat.get(y).size(); x++) {
+ final T center = mat.get(y).get(x);
+ R result = init.get();
+ for (int dy = -axes.getY(); dy <= axes.getY(); dy++) {
+ final int ry = y + dy;
+ if (ry < 0 || ry >= mat.size())
+ continue;
+ for (int dx = -axes.getX(); dx <= axes.getX(); dx++) {
+ final int rx = x + dx;
+ if (rx < 0 || rx >= mat.get(ry).size())
+ continue;
+ result = convolver.convolve(mat.get(ry).get(rx), Vec2i.builder().x(dx).y(dy).build(), result);
+ }
+ }
+ row.add(result);
+ }
+ rows.add(row);
+ }
+
+ final List<List<U>> reductions = new ArrayList<>();
+ for (int y = 0; y < mat.size(); y++) {
+ final List<U> reduction = new ArrayList<>(mat.get(y).size());
+ for (int x = 0; x < mat.get(y).size(); x++) {
+ reduction.add(finalReduction.apply(mat.get(y).get(x), rows.get(y).get(x)));
+ }
+ reductions.add(reduction);
+ }
+ return reductions;
+ }
+
+ @FunctionalInterface
+ public interface Convolver<T, R> {
+ R convolve(final T center, final Vec2<Integer> rel, final R reduction);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/math/Vec2.java b/core/src/main/java/coffee/liz/ecs/math/Vec2.java
new file mode 100644
index 0000000..ec7e531
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/math/Vec2.java
@@ -0,0 +1,65 @@
+package coffee.liz.ecs.math;
+
+/**
+ * Cartesian vectors.
+ *
+ * @param <T>
+ * the numeric type of vector components
+ */
+public interface Vec2<T> {
+ /**
+ * @return the x coordinate
+ */
+ T getX();
+
+ /**
+ * @return the y coordinate
+ */
+ T getY();
+
+ /**
+ * Adds another vector to this vector.
+ *
+ * @param other
+ * the vector to add
+ * @return a new vector with the result
+ */
+ Vec2<T> plus(final Vec2<T> other);
+
+ /**
+ * Subtracts another vector from this vector.
+ *
+ * @param other
+ * the vector to subtract
+ * @return a new vector with the result
+ */
+ Vec2<T> minus(final Vec2<T> other);
+
+ /**
+ * Scales this vector by the given factors.
+ *
+ * @param scaleX
+ * the x scale factor
+ * @param scaleY
+ * the y scale factor
+ * @return a new scaled vector
+ */
+ Vec2<T> scale(final T scaleX, final T scaleY);
+
+ /**
+ * Length of the vector.
+ *
+ * @return length.
+ */
+ float length();
+
+ /**
+ * @return Vec2<Integer> components of {@link Vec2<T>}
+ */
+ Vec2<Integer> intValue();
+
+ /**
+ * @return Vec2<Float> components of {@link Vec2<T>}
+ */
+ Vec2<Float> floatValue();
+}
diff --git a/core/src/main/java/coffee/liz/ecs/math/Vec2f.java b/core/src/main/java/coffee/liz/ecs/math/Vec2f.java
new file mode 100644
index 0000000..46f3fb8
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/math/Vec2f.java
@@ -0,0 +1,54 @@
+package coffee.liz.ecs.math;
+
+import static java.lang.Math.sqrt;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/** Float impl of {@link Vec2}. */
+@Getter
+@RequiredArgsConstructor
+@Data
+@Builder
+public final class Vec2f implements Vec2<Float> {
+ /** X coordinate. */
+ private final Float x;
+
+ /** Y coordinate. */
+ private final Float y;
+
+ @Override
+ public Vec2<Float> plus(final Vec2<Float> other) {
+ return new Vec2f(x + other.getX(), y + other.getY());
+ }
+
+ @Override
+ public Vec2<Float> minus(final Vec2<Float> other) {
+ return new Vec2f(x - other.getX(), y - other.getY());
+ }
+
+ @Override
+ public Vec2<Float> scale(final Float scaleX, final Float scaleY) {
+ return new Vec2f(x * scaleX, y * scaleY);
+ }
+
+ @Override
+ public float length() {
+ return (float) sqrt(x * x + y * y);
+ }
+
+ @Override
+ public Vec2<Float> floatValue() {
+ return this;
+ }
+
+ @Override
+ public Vec2<Integer> intValue() {
+ return Vec2i.builder().x(this.x.intValue()).y(this.y.intValue()).build();
+ }
+
+ /** Zero float vec */
+ public static Vec2<Float> ZERO = Vec2f.builder().x(0f).y(0f).build();
+}
diff --git a/core/src/main/java/coffee/liz/ecs/math/Vec2i.java b/core/src/main/java/coffee/liz/ecs/math/Vec2i.java
new file mode 100644
index 0000000..dbe246e
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/math/Vec2i.java
@@ -0,0 +1,57 @@
+package coffee.liz.ecs.math;
+
+import static java.lang.Math.sqrt;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/** Integer impl of {@link Vec2}. */
+@Getter
+@RequiredArgsConstructor
+@Builder
+@Data
+public final class Vec2i implements Vec2<Integer> {
+ /** X coordinate. */
+ private final Integer x;
+
+ /** Y coordinate. */
+ private final Integer y;
+
+ @Override
+ public Vec2<Integer> plus(final Vec2<Integer> other) {
+ return new Vec2i(x + other.getX(), y + other.getY());
+ }
+
+ @Override
+ public Vec2<Integer> minus(final Vec2<Integer> other) {
+ return new Vec2i(x - other.getX(), y - other.getY());
+ }
+
+ @Override
+ public Vec2<Integer> scale(final Integer scaleX, final Integer scaleY) {
+ return new Vec2i(x * scaleX, y * scaleY);
+ }
+
+ @Override
+ public Vec2<Float> floatValue() {
+ return Vec2f.builder().x(this.x.floatValue()).y(this.y.floatValue()).build();
+ }
+
+ @Override
+ public Vec2<Integer> intValue() {
+ return this;
+ }
+
+ @Override
+ public float length() {
+ return (float) sqrt(x * x + y * y);
+ }
+
+ public static final Vec2i NORTH = new Vec2i(0, -1);
+ public static final Vec2i SOUTH = new Vec2i(0, 1);
+ public static final Vec2i EAST = new Vec2i(1, 0);
+ public static final Vec2i WEST = new Vec2i(-1, 0);
+ public static final Vec2i ZERO = new Vec2i(0, 0);
+}
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..f96ba95
--- /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<? extends Component> 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..e820e57
--- /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<Class<? extends Component>, 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<? extends Component> 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<Class<? extends Component>> components) {
+ return components.stream().allMatch(this::has);
+ }
+
+ /**
+ * Get component by type.
+ *
+ * @param componentType
+ * the {@link Component} class
+ * @param <C>
+ * component type
+ * @return the component or throw {@link IllegalArgumentException}
+ */
+ @SuppressWarnings("unchecked")
+ public <C extends Component> C get(final Class<C> 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 <C>
+ * component type
+ * @return this {@link Entity} for chaining
+ */
+ public <C extends Component> 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 <C>
+ * component type
+ * @return this {@link Entity} for chaining
+ */
+ public <C extends Component> Entity remove(final Class<C> componentType) {
+ componentMap.remove(componentType);
+ return this;
+ }
+
+ /**
+ * Get all component types.
+ *
+ * @return set of {@link Component} classes
+ */
+ public Set<Class<? extends Component>> componentTypes() {
+ return componentMap.keySet();
+ }
+}
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..220b917
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/System.java
@@ -0,0 +1,29 @@
+package coffee.liz.ecs.model;
+
+import java.time.Duration;
+import java.util.Collection;
+
+/**
+ * Updates the {@link World} state.
+ *
+ * @param <T>
+ * is the state of the stuff outside the {@link World}.
+ */
+public interface System<T> {
+ /**
+ * {@link System} clazzes that must run before this system.
+ *
+ * @return {@link Collection} of dependencies.
+ */
+ Collection<Class<? extends System<T>>> getDependencies();
+
+ /**
+ * @param world
+ * Is the {@link World}.
+ * @param state
+ * Is the {@link T} state outside the {@param world}.
+ * @param dt
+ * Is the timestep.
+ */
+ void update(final World<T> world, final T state, final Duration dt);
+}
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..96c7a74
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/World.java
@@ -0,0 +1,58 @@
+package coffee.liz.ecs.model;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * The state of the world.
+ *
+ * @param <T>
+ * is the state of the stuff outside the world.
+ */
+public interface World<T> {
+ /**
+ * 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 components
+ * to query for.
+ * @return All entities with all {@param components}.
+ */
+ Set<Entity> query(final Collection<Class<? extends Component>> components);
+
+ /**
+ * Integrate the {@link World}.
+ *
+ * @param state
+ * Is the state outside the world.
+ * @param duration
+ * Is the time step.
+ */
+ void update(final T state, final Duration duration);
+
+ /**
+ * Get world {@link System}.
+ *
+ * @param system
+ * is the Clazz.
+ * @param <S>
+ * is the {@link System} type.
+ * @return {@link System} instance of {@param system}.
+ */
+ <S extends System<T>> S getSystem(final Class<S> system);
+}
diff --git a/core/src/main/java/coffee/liz/lambda/LambdaDriver.java b/core/src/main/java/coffee/liz/lambda/LambdaDriver.java
new file mode 100644
index 0000000..2ba8c70
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/LambdaDriver.java
@@ -0,0 +1,79 @@
+package coffee.liz.lambda;
+
+import coffee.liz.lambda.parser.ArrowParser;
+import coffee.liz.lambda.parser.LambdaParser;
+import coffee.liz.lambda.parser.ParseException;
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.SourceCode;
+import coffee.liz.lambda.bind.ExternalBinding;
+import coffee.liz.lambda.eval.Environment;
+import coffee.liz.lambda.eval.NormalOrderEvaluator;
+import coffee.liz.lambda.eval.Value;
+
+import java.io.StringReader;
+import java.util.List;
+
+/**
+ * Entry point for parsing and interpreting lambda calculus programs.
+ */
+public class LambdaDriver {
+
+ /**
+ * Parses source code into an AST.
+ *
+ * @param sourceCode
+ * the source code (either Lambda or Arrow syntax)
+ * @return the parsed program
+ */
+ public static LambdaProgram parse(final SourceCode sourceCode) {
+ return switch (sourceCode) {
+ case SourceCode.Lambda(String source) -> parseLambda(source);
+ case SourceCode.Arrow(String source) -> parseArrow(source);
+ };
+ }
+
+ private static LambdaProgram parseLambda(final String source) {
+ try (final StringReader reader = new StringReader(source)) {
+ return new LambdaParser(reader).Program();
+ } catch (final ParseException parseException) {
+ throw new RuntimeException("Failed to parse program", parseException);
+ }
+ }
+
+ private static LambdaProgram parseArrow(final String source) {
+ try (final StringReader reader = new StringReader(source)) {
+ return new ArrowParser(reader).Program();
+ } catch (final ParseException parseException) {
+ throw new RuntimeException("Failed to parse program", parseException);
+ }
+ }
+
+ /**
+ * Parses and evaluates lambda calculus programs.
+ *
+ * @param sourceCode
+ * the source code
+ * @return the evaluated result
+ */
+ public static Value interpret(final SourceCode sourceCode) {
+ return interpret(sourceCode, List.of());
+ }
+
+ /**
+ * Parses and evaluates lambda calculus programs with "FFI"'s.
+ *
+ * @param sourceCode
+ * the source code
+ * @param bindings
+ * external Java functions available during evaluation
+ * @return the evaluated result
+ */
+ public static Value interpret(final SourceCode sourceCode, final List<ExternalBinding> bindings) {
+ final LambdaProgram program = parse(sourceCode);
+ final Expression expression = program.expression();
+ final List<Macro> macros = program.macros();
+ return NormalOrderEvaluator.evaluate(expression, Environment.from(macros, bindings));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/Expression.java b/core/src/main/java/coffee/liz/lambda/ast/Expression.java
new file mode 100644
index 0000000..6d75a08
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/Expression.java
@@ -0,0 +1,28 @@
+package coffee.liz.lambda.ast;
+
+import lombok.NonNull;
+
+import java.util.Optional;
+
+/**
+ * Represents an expression in the untyped lambda calculus.
+ */
+public sealed interface Expression
+ permits Expression.AbstractionExpression, Expression.IdentifierExpression, Expression.ApplicationExpression {
+
+ Optional<SourceComment> comment();
+
+ SourceSpan span();
+
+ record AbstractionExpression(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span,
+ @NonNull String parameter, @NonNull Expression body) implements Expression {
+ }
+
+ record ApplicationExpression(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span,
+ @NonNull Expression applicable, @NonNull Expression argument) implements Expression {
+ }
+
+ record IdentifierExpression(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span,
+ @NonNull String name) implements Expression {
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java b/core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java
new file mode 100644
index 0000000..efd4c03
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java
@@ -0,0 +1,19 @@
+package coffee.liz.lambda.ast;
+
+import lombok.NonNull;
+
+import java.util.List;
+
+/**
+ * A complete lambda calculus program consisting of macro definitions and a main
+ * expression.
+ *
+ * @param span
+ * source span covering the entire program
+ * @param macros
+ * named macro definitions that can be referenced in the expression
+ * @param expression
+ * the main expression to evaluate
+ */
+public record LambdaProgram(@NonNull SourceSpan span, @NonNull List<Macro> macros, @NonNull Expression expression) {
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/Macro.java b/core/src/main/java/coffee/liz/lambda/ast/Macro.java
new file mode 100644
index 0000000..07ba911
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/Macro.java
@@ -0,0 +1,12 @@
+package coffee.liz.lambda.ast;
+
+import lombok.NonNull;
+
+import java.util.Optional;
+
+/**
+ * A named macro definition that maps an identifier to an expression.
+ */
+public record Macro(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span, @NonNull String name,
+ @NonNull Expression expression) {
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/SourceCode.java b/core/src/main/java/coffee/liz/lambda/ast/SourceCode.java
new file mode 100644
index 0000000..200c45e
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/SourceCode.java
@@ -0,0 +1,27 @@
+package coffee.liz.lambda.ast;
+
+/**
+ * Represents source code in one of the supported lambda calculus syntaxes.
+ */
+public sealed interface SourceCode {
+ static SourceCode ofLambda(final String source) {
+ return new Lambda(source);
+ }
+
+ static SourceCode ofArrow(final String source) {
+ return new Arrow(source);
+ }
+
+ record Lambda(String source) implements SourceCode {
+ }
+
+ record Arrow(String source) implements SourceCode {
+ }
+
+ /**
+ * Supported syntax types for {@link SourceCode}.
+ */
+ enum Syntax {
+ LAMBDA, ARROW
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/SourceComment.java b/core/src/main/java/coffee/liz/lambda/ast/SourceComment.java
new file mode 100644
index 0000000..da6b5ab
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/SourceComment.java
@@ -0,0 +1,21 @@
+package coffee.liz.lambda.ast;
+
+import lombok.NonNull;
+
+/**
+ * A comment with its source location; inline vs leading direction.
+ *
+ * @param text
+ * the comment text content
+ * @param span
+ * the source location of the comment
+ */
+public record SourceComment(@NonNull String text, @NonNull SourceSpan span) {
+
+ /**
+ * Returns true if this comment is on the same line as the given span's end.
+ */
+ public boolean isInlineAfter(final SourceSpan previous) {
+ return previous != null && previous.endLine() == this.span.startLine();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java b/core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java
new file mode 100644
index 0000000..7df9bcd
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java
@@ -0,0 +1,24 @@
+package coffee.liz.lambda.ast;
+
+/**
+ * Span of source code with start and end positions.
+ *
+ * @param startLine
+ * 1-based line number where the span starts
+ * @param startColumn
+ * 1-based column number where the span starts
+ * @param endLine
+ * 1-based line number where the span ends
+ * @param endColumn
+ * 1-based column number where the span ends
+ */
+public record SourceSpan(int startLine, int startColumn, int endLine, int endColumn) {
+ public static final SourceSpan UNKNOWN = new SourceSpan(0, 0, 0, 0);
+
+ /**
+ * Returns true if this span ends on the same line that the other span starts.
+ */
+ public boolean isOnSameLine(final SourceSpan other) {
+ return this.endLine == other.startLine;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java b/core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java
new file mode 100644
index 0000000..4895972
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java
@@ -0,0 +1,23 @@
+package coffee.liz.lambda.bind;
+
+import coffee.liz.lambda.eval.Environment;
+import coffee.liz.lambda.eval.Value;
+
+import java.util.function.BiFunction;
+
+/**
+ * Interface for external Java functions callable from lambda expressions.
+ *
+ * <p>
+ * Implementations receive the current environment and an argument value, and
+ * return a result value.
+ */
+public interface ExternalBinding extends BiFunction<Environment, Value, Value> {
+
+ /**
+ * Returns the name used to reference this binding in environment.
+ *
+ * @return the binding name
+ */
+ String getName();
+}
diff --git a/core/src/main/java/coffee/liz/lambda/bind/Tick.java b/core/src/main/java/coffee/liz/lambda/bind/Tick.java
new file mode 100644
index 0000000..0fa4de5
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/bind/Tick.java
@@ -0,0 +1,22 @@
+package coffee.liz.lambda.bind;
+
+import coffee.liz.lambda.eval.Environment;
+import coffee.liz.lambda.eval.Value;
+import lombok.Getter;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Identity function that has a side effect which internally counts invocations.
+ */
+@Getter
+public class Tick implements ExternalBinding {
+ private final String name = "Tick";
+ private final AtomicInteger counter = new AtomicInteger(0);
+
+ @Override
+ public Value apply(final Environment environment, final Value value) {
+ counter.incrementAndGet();
+ return value;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/bind/ToChurch.java b/core/src/main/java/coffee/liz/lambda/bind/ToChurch.java
new file mode 100644
index 0000000..bfddd86
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/bind/ToChurch.java
@@ -0,0 +1,49 @@
+package coffee.liz.lambda.bind;
+
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.SourceSpan;
+import coffee.liz.lambda.eval.Environment;
+
+import java.util.Optional;
+import coffee.liz.lambda.eval.Value;
+import coffee.liz.lambda.eval.Value.Free;
+import coffee.liz.lambda.eval.Value.Closure;
+import lombok.Getter;
+
+/**
+ * Converts an integer to its Church numeral representation.
+ *
+ * <p>
+ * Church numerals encode n as {@code λf.λx.f(f(...f(x)...))} with n
+ * applications of f.
+ */
+@Getter
+public class ToChurch implements ExternalBinding {
+ private final String name = "ToChurch";
+
+ /**
+ * Converts a free variable containing an integer string to a Church numeral.
+ *
+ * @param env
+ * the current environment
+ * @param val
+ * a Free value whose name is an integer string
+ * @return a Closure representing the Church numeral
+ */
+ @Override
+ public Value apply(final Environment env, final Value val) {
+ final Free free = (Free) val;
+ final int n = Integer.parseInt(free.name());
+
+ Expression body = new IdentifierExpression(Optional.empty(), SourceSpan.UNKNOWN, "x");
+ for (int i = 0; i < n; i++) {
+ body = new ApplicationExpression(Optional.empty(), SourceSpan.UNKNOWN,
+ new IdentifierExpression(Optional.empty(), SourceSpan.UNKNOWN, "f"), body);
+ }
+
+ return new Closure(env, "f", new AbstractionExpression(Optional.empty(), SourceSpan.UNKNOWN, "x", body));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/eval/Environment.java b/core/src/main/java/coffee/liz/lambda/eval/Environment.java
new file mode 100644
index 0000000..8854719
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/Environment.java
@@ -0,0 +1,109 @@
+package coffee.liz.lambda.eval;
+
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.bind.ExternalBinding;
+import jakarta.annotation.Nullable;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+/**
+ * Runtime environment for variable bindings, macros, and external bindings.
+ */
+@RequiredArgsConstructor
+public final class Environment {
+ /** Named expansions */
+ private final Map<String, Expression> macros;
+
+ /** "FFI" */
+ private final Map<String, ExternalBinding> externalBindings;
+
+ /** Variable name bound at this scope level. Null for root. */
+ @Nullable
+ private final String boundName;
+
+ /** Lazily-evaluated value for boundName, or null for root. */
+ @Nullable
+ private final Supplier<Value> boundValue;
+
+ /** Enclosing scope, or null for root. Forms a linked list of bindings. */
+ @Nullable
+ private final Environment parent;
+
+ /**
+ * Creates an environment from macro and external binding lists.
+ *
+ * @param macros
+ * program macro definitions
+ * @param externalBindings
+ * external Java bindings for FFI
+ * @return the new environment
+ */
+ public static Environment from(final List<Macro> macros, final List<ExternalBinding> externalBindings) {
+ return new Environment(macros.stream().collect(Collectors.toMap(Macro::name, Macro::expression)),
+ externalBindings.stream().collect(Collectors.toMap(ExternalBinding::getName, Function.identity())),
+ null, null, null);
+ }
+
+ /**
+ * Creates a child scope.
+ *
+ * @param name
+ * the variable name
+ * @param value
+ * the value supplier (thunk)
+ * @return a new environment with the binding added
+ */
+ public Environment extend(final String name, final Supplier<Value> value) {
+ return new Environment(macros, externalBindings, name, value, this);
+ }
+
+ /**
+ * Looks up a name, checking bindings, then macros, then external bindings.
+ *
+ * @param name
+ * the name to look up
+ * @return the lookup result, or empty if not found
+ */
+ public Optional<LookupResult> lookup(final String name) {
+ for (Environment env = this; env != null; env = env.parent) {
+ if (!name.equals(env.boundName)) {
+ continue;
+ }
+ return Optional.of(new LookupResult.Binding(env.boundValue));
+ }
+
+ final Expression macro = macros.get(name);
+ if (macro != null) {
+ return Optional.of(new LookupResult.Macro(macro));
+ }
+
+ final ExternalBinding external = externalBindings.get(name);
+ if (external != null) {
+ return Optional.of(new LookupResult.External(external));
+ }
+
+ return Optional.empty();
+ }
+
+ /**
+ * Result of looking up a name in the environment.
+ */
+ public sealed interface LookupResult {
+ /** A local variable binding. */
+ record Binding(Supplier<Value> value) implements LookupResult {
+ }
+ /** A macro definition. */
+ record Macro(Expression expression) implements LookupResult {
+ }
+ /** An external Java binding. */
+ record External(ExternalBinding binding) implements LookupResult {
+ }
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java b/core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java
new file mode 100644
index 0000000..7926004
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java
@@ -0,0 +1,16 @@
+package coffee.liz.lambda.eval;
+
+import lombok.Getter;
+
+/**
+ * Thrown before we get a {@link StackOverflowError}, hopefully.
+ */
+@Getter
+public final class EvaluationDepthExceededException extends RuntimeException {
+ private final int maxDepth;
+
+ public EvaluationDepthExceededException(final int maxDepth) {
+ super("Evaluation exceeded maximum depth of " + maxDepth);
+ this.maxDepth = maxDepth;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java b/core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java
new file mode 100644
index 0000000..28eb69b
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java
@@ -0,0 +1,101 @@
+package coffee.liz.lambda.eval;
+
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.bind.ExternalBinding;
+import coffee.liz.lambda.eval.Environment.LookupResult;
+import coffee.liz.lambda.eval.Value.Application;
+import coffee.liz.lambda.eval.Value.Closure;
+import coffee.liz.lambda.eval.Value.Free;
+
+/**
+ * Evaluates lambda expressions using normal order (lazy) reduction.
+ *
+ * <p>
+ * Arguments are wrapped in {@link Thunk} and only evaluated when needed.
+ */
+public final class NormalOrderEvaluator {
+
+ public static final int DEFAULT_MAX_DEPTH = 140;
+
+ /**
+ * Evaluates an expression in the given environment with default depth limit.
+ *
+ * @param term
+ * the expression to evaluate
+ * @param env
+ * the environment containing bindings
+ * @return the resulting value
+ * @throws EvaluationDepthExceededException
+ * if evaluation exceeds {@link #DEFAULT_MAX_DEPTH}
+ */
+ public static Value evaluate(final Expression term, final Environment env) {
+ return evaluate(term, env, DEFAULT_MAX_DEPTH);
+ }
+
+ /**
+ * Evaluates an expression in the given environment with specified depth limit.
+ *
+ * @param term
+ * the expression to evaluate
+ * @param env
+ * the environment containing bindings
+ * @param maxDepth
+ * maximum evaluation depth
+ * @return the resulting value
+ * @throws EvaluationDepthExceededException
+ * if evaluation exceeds maxDepth
+ */
+ public static Value evaluate(final Expression term, final Environment env, final int maxDepth) {
+ return evaluate(term, env, maxDepth, 0);
+ }
+
+ private static Value evaluate(final Expression term, final Environment env, final int maxDepth, final int depth) {
+ if (depth > maxDepth) {
+ throw new EvaluationDepthExceededException(maxDepth);
+ }
+
+ return switch (term) {
+ case IdentifierExpression(var _, var _, final String name) ->
+ env.lookup(name).map(result -> switch (result) {
+ case LookupResult.Binding(final var value) -> value.get();
+ case LookupResult.Macro(final var expr) -> evaluate(expr, env, maxDepth, depth + 1);
+ case LookupResult.External(final var binding) -> new Free(binding.getName());
+ }).orElseGet(() -> new Free(name));
+
+ case AbstractionExpression(var _, var _, final String parameter, final Expression body) ->
+ new Closure(env, parameter, body);
+
+ case ApplicationExpression(var _, var _, final Expression func, final Expression arg) ->
+ apply(evaluate(func, env, maxDepth, depth + 1), arg, env, maxDepth, depth + 1);
+ };
+ }
+
+ private static Value apply(final Value funcVal, final Expression arg, final Environment argEnv, final int maxDepth,
+ final int depth) {
+ if (depth > maxDepth) {
+ throw new EvaluationDepthExceededException(maxDepth);
+ }
+
+ return switch (funcVal) {
+ case Closure(final Environment closureEnv, final String parameter, final Expression body) -> {
+ final Thunk<Value> thunk = new Thunk<>(() -> evaluate(arg, argEnv, maxDepth, depth + 1));
+ final Environment newEnv = closureEnv.extend(parameter, thunk);
+ yield evaluate(body, newEnv, maxDepth, depth + 1);
+ }
+
+ case Free(final String name) ->
+ argEnv.lookup(name).filter(r -> r instanceof LookupResult.External).map(r -> {
+ final ExternalBinding binding = ((LookupResult.External) r).binding();
+ return binding.apply(argEnv, evaluate(arg, argEnv, maxDepth, depth + 1));
+ }).orElseGet(() -> new Application(funcVal, evaluate(arg, argEnv, maxDepth, depth + 1)));
+
+ case Application app -> {
+ final Value argVal = evaluate(arg, argEnv, maxDepth, depth + 1);
+ yield new Application(app, argVal);
+ }
+ };
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/eval/Thunk.java b/core/src/main/java/coffee/liz/lambda/eval/Thunk.java
new file mode 100644
index 0000000..07c89bf
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/Thunk.java
@@ -0,0 +1,27 @@
+package coffee.liz.lambda.eval;
+
+import lombok.RequiredArgsConstructor;
+
+import java.util.function.Supplier;
+
+/**
+ * A memoizing thunk for lazy evaluation.
+ *
+ * @param <T>
+ * Thunk type
+ */
+@RequiredArgsConstructor
+public final class Thunk<T> implements Supplier<T> {
+ private final Supplier<T> thinker; // https://www.youtube.com/shorts/Dzksib8YxSY
+ private T cached = null;
+ private boolean evaluated = false;
+
+ @Override
+ public T get() {
+ if (!evaluated) {
+ cached = thinker.get();
+ evaluated = true;
+ }
+ return cached;
+ }
+} \ No newline at end of file
diff --git a/core/src/main/java/coffee/liz/lambda/eval/Value.java b/core/src/main/java/coffee/liz/lambda/eval/Value.java
new file mode 100644
index 0000000..58b9ba4
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/Value.java
@@ -0,0 +1,42 @@
+package coffee.liz.lambda.eval;
+
+import coffee.liz.lambda.ast.Expression;
+
+/**
+ * Represents a runtime value produced by evaluation.
+ */
+public sealed interface Value permits Value.Closure, Value.Application, Value.Free {
+
+ /**
+ * A closure capturing an environment, parameter, and body.
+ *
+ * @param env
+ * the captured environment
+ * @param parameter
+ * the bound parameter name
+ * @param body
+ * the lambda body expression
+ */
+ record Closure(Environment env, String parameter, Expression body) implements Value {
+ }
+
+ /**
+ * A symbolic application of a function to an argument.
+ *
+ * @param function
+ * the function
+ * @param argument
+ * the argument
+ */
+ record Application(Value function, Value argument) implements Value {
+ }
+
+ /**
+ * A free variable.
+ *
+ * @param name
+ * the variable name
+ */
+ record Free(String name) implements Value {
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/format/Formatter.java b/core/src/main/java/coffee/liz/lambda/format/Formatter.java
new file mode 100644
index 0000000..1ac3e95
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/format/Formatter.java
@@ -0,0 +1,238 @@
+package coffee.liz.lambda.format;
+
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.SourceComment;
+import coffee.liz.lambda.ast.SourceSpan;
+import lombok.RequiredArgsConstructor;
+import coffee.liz.lambda.ast.SourceCode.Syntax;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Formats lambda calculus ASTs.
+ */
+@RequiredArgsConstructor
+public final class Formatter {
+
+ private final Syntax syntax;
+
+ public String format(final LambdaProgram program) {
+ final StringBuilder sb = new StringBuilder();
+ SourceSpan previousEnd = null;
+
+ for (int i = 0; i < program.macros().size(); i++) {
+ final Macro macro = program.macros().get(i);
+
+ if (macro.comment().isPresent()) {
+ final SourceComment comment = macro.comment().get();
+ final String leadingPart = getLeadingCommentPart(comment, previousEnd);
+ if (!leadingPart.isEmpty()) {
+ sb.append(leadingPart);
+ if (!leadingPart.endsWith("\n")) {
+ sb.append("\n");
+ }
+ }
+ }
+
+ sb.append("let ").append(macro.name()).append(" = ");
+ sb.append(formatExpression(macro.expression(), false));
+ sb.append(";");
+
+ final Optional<String> inlineComment = getInlineCommentAfter(macro.span(), program, i);
+ inlineComment.ifPresent(c -> sb.append(" ").append(c));
+ sb.append("\n");
+
+ previousEnd = macro.span();
+ }
+
+ boolean isTrailingComment = false;
+ boolean inlinePartAlreadyOutput = false;
+
+ if (program.expression().comment().isPresent()) {
+ final SourceComment comment = program.expression().comment().get();
+
+ isTrailingComment = comment.span().startLine() >= program.expression().span().startLine()
+ && comment.span().startColumn() > program.expression().span().startColumn();
+
+ if (!isTrailingComment) {
+ inlinePartAlreadyOutput = previousEnd != null && comment.isInlineAfter(previousEnd);
+
+ final String leadingPart = getLeadingCommentPart(comment, previousEnd);
+ if (!leadingPart.isEmpty()) {
+ sb.append(leadingPart);
+ if (!leadingPart.endsWith("\n")) {
+ sb.append("\n");
+ }
+ } else if (!program.macros().isEmpty()) {
+ sb.append("\n");
+ }
+ } else if (!program.macros().isEmpty()) {
+ sb.append("\n");
+ }
+ } else if (!program.macros().isEmpty()) {
+ sb.append("\n");
+ }
+
+ sb.append(formatExpression(program.expression(), false));
+
+ if (program.expression().comment().isPresent() && !inlinePartAlreadyOutput && isTrailingComment) {
+ sb.append(" ").append(program.expression().comment().get().text());
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Gets the inline portion of a comment (first line if on same line as previous
+ * element).
+ */
+ private String getInlineCommentPart(final SourceComment comment, final SourceSpan previousEnd) {
+ if (previousEnd == null || !comment.isInlineAfter(previousEnd)) {
+ return "";
+ }
+ final String text = comment.text();
+ final int newlineIndex = text.indexOf('\n');
+ return newlineIndex == -1 ? text : text.substring(0, newlineIndex);
+ }
+
+ /**
+ * Gets the leading portion of a comment (all lines except inline first line).
+ */
+ private String getLeadingCommentPart(final SourceComment comment, final SourceSpan previousEnd) {
+ if (previousEnd == null || !comment.isInlineAfter(previousEnd)) {
+ return comment.text();
+ }
+ final String text = comment.text();
+ final int newlineIndex = text.indexOf('\n');
+ return newlineIndex == -1 ? "" : text.substring(newlineIndex + 1);
+ }
+
+ /**
+ * Gets the inline comment that appears after the given span, if any.
+ */
+ private Optional<String> getInlineCommentAfter(final SourceSpan span, final LambdaProgram program,
+ final int macroIndex) {
+ if (macroIndex + 1 < program.macros().size()) {
+ final Macro nextMacro = program.macros().get(macroIndex + 1);
+ if (nextMacro.comment().isPresent() && nextMacro.comment().get().isInlineAfter(span)) {
+ final String inlinePart = getInlineCommentPart(nextMacro.comment().get(), span);
+ if (!inlinePart.isEmpty()) {
+ return Optional.of(inlinePart);
+ }
+ }
+ }
+ if (macroIndex == program.macros().size() - 1 && program.expression().comment().isPresent()
+ && program.expression().comment().get().isInlineAfter(span)) {
+ final String inlinePart = getInlineCommentPart(program.expression().comment().get(), span);
+ if (!inlinePart.isEmpty()) {
+ return Optional.of(inlinePart);
+ }
+ }
+ return Optional.empty();
+ }
+
+ private String formatExpression(final Expression expr, final boolean needsParens) {
+ return switch (expr) {
+ case AbstractionExpression abs -> formatAbstraction(abs, needsParens);
+ case ApplicationExpression app -> formatApplication(app, needsParens);
+ case IdentifierExpression id -> id.name();
+ };
+ }
+
+ private String formatAbstraction(final AbstractionExpression abs, final boolean needsParens) {
+ final StringBuilder sb = new StringBuilder();
+
+ if (needsParens) {
+ sb.append("(");
+ }
+ switch (syntax) {
+ case LAMBDA -> {
+ sb.append("λ").append(abs.parameter()).append(".");
+ sb.append(formatExpression(abs.body(), false));
+ }
+ case ARROW -> {
+ sb.append(abs.parameter()).append(" -> ");
+ sb.append(formatExpression(abs.body(), false));
+ }
+ }
+ if (needsParens) {
+ sb.append(")");
+ }
+
+ return sb.toString();
+ }
+
+ private String formatApplication(final ApplicationExpression app, final boolean needsParens) {
+ return switch (syntax) {
+ case LAMBDA -> formatLambdaApplication(app, needsParens);
+ case ARROW -> formatArrowApplication(app);
+ };
+ }
+
+ private String formatLambdaApplication(final ApplicationExpression app, final boolean needsParens) {
+ final StringBuilder sb = new StringBuilder();
+
+ if (needsParens) {
+ sb.append("(");
+ }
+
+ final boolean funcNeedsParens = app.applicable() instanceof AbstractionExpression;
+ sb.append(formatExpression(app.applicable(), funcNeedsParens));
+
+ sb.append(" ");
+
+ final boolean argNeedsParens = app.argument() instanceof ApplicationExpression
+ || app.argument() instanceof AbstractionExpression;
+ sb.append(formatExpression(app.argument(), argNeedsParens));
+
+ if (needsParens) {
+ sb.append(")");
+ }
+
+ return sb.toString();
+ }
+
+ private String formatArrowApplication(final ApplicationExpression app) {
+ final StringBuilder sb = new StringBuilder();
+
+ Expression func = app.applicable();
+ final List<Expression> args = new ArrayList<>();
+ args.add(app.argument());
+
+ while (func instanceof ApplicationExpression appFunc) {
+ args.addFirst(appFunc.argument());
+ func = appFunc.applicable();
+ }
+
+ final boolean funcNeedsParens = func instanceof AbstractionExpression;
+ if (funcNeedsParens) {
+ sb.append("(");
+ }
+ sb.append(formatExpression(func, false));
+ if (funcNeedsParens) {
+ sb.append(")");
+ }
+
+ for (final Expression arg : args) {
+ sb.append("(");
+ sb.append(formatExpression(arg, false));
+ sb.append(")");
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Formats a program in the specified syntax.
+ */
+ public static String emit(final LambdaProgram program, final Syntax syntax) {
+ return new Formatter(syntax).format(program);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt b/core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt
new file mode 100644
index 0000000..9ad9099
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt
@@ -0,0 +1,276 @@
+options {
+ STATIC = false;
+ LOOKAHEAD = 2;
+ UNICODE_INPUT = true;
+ NODE_PREFIX = "AST";
+}
+
+PARSER_BEGIN(ArrowParser)
+package coffee.liz.lambda.parser;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.SourceSpan;
+import coffee.liz.lambda.ast.SourceComment;
+
+public class ArrowParser {
+ public static void main(final String[] args) throws Exception {
+ final ArrowParser parser = new ArrowParser(System.in);
+ final LambdaProgram program = parser.Program();
+ System.out.println(program);
+ }
+
+ private static SourceSpan spanFrom(final Token start, final Token end) {
+ return new SourceSpan(start.beginLine, start.beginColumn, end.endLine, end.endColumn);
+ }
+
+ private static SourceSpan spanFrom(final Token start, final Expression end) {
+ return new SourceSpan(start.beginLine, start.beginColumn, end.span().endLine(), end.span().endColumn());
+ }
+
+ private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Token end) {
+ return new SourceSpan(startLine, startColumn, end.endLine, end.endColumn);
+ }
+
+ private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Expression end) {
+ return new SourceSpan(startLine, startColumn, end.span().endLine(), end.span().endColumn());
+ }
+
+ private static Expression withComment(final Expression expr, final Optional<SourceComment> comment) {
+ if (comment.isEmpty()) {
+ return expr;
+ }
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(comment, a.span(), a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(comment, a.span(), a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(comment, a.span(), a.name());
+ }
+ return expr;
+ }
+
+ private static Expression withSpan(final Expression expr, final SourceSpan span) {
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(a.comment(), span, a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(a.comment(), span, a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(a.comment(), span, a.name());
+ }
+ return expr;
+ }
+
+ private static Expression withCommentAndSpan(final Expression expr, final Optional<SourceComment> comment, final SourceSpan span) {
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(comment, span, a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(comment, span, a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(comment, span, a.name());
+ }
+ return expr;
+ }
+
+ private static Optional<SourceComment> extractComment(final Token t) {
+ if (t == null || t.specialToken == null) {
+ return Optional.empty();
+ }
+ Token st = t.specialToken;
+ while (st.specialToken != null) {
+ st = st.specialToken;
+ }
+ final Token firstComment = st;
+ final StringBuilder sb = new StringBuilder();
+ Token lastComment = st;
+ int lastEndLine = 0;
+ while (st != null) {
+ if (sb.length() > 0) {
+ final int blankLines = st.beginLine - lastEndLine - 1;
+ for (int i = 0; i < blankLines; i++) {
+ sb.append("\n");
+ }
+ sb.append("\n");
+ }
+ String image = st.image;
+ while (image.endsWith("\n") || image.endsWith("\r")) {
+ image = image.substring(0, image.length() - 1);
+ }
+ sb.append(image);
+ lastEndLine = st.endLine;
+ lastComment = st;
+ st = st.next;
+ }
+ final SourceSpan span = new SourceSpan(
+ firstComment.beginLine, firstComment.beginColumn,
+ lastComment.endLine, lastComment.endColumn
+ );
+ return Optional.of(new SourceComment(sb.toString(), span));
+ }
+}
+PARSER_END(ArrowParser)
+
+SKIP : {
+ " " | "\t" | "\r" | "\n"
+}
+
+SPECIAL_TOKEN : {
+ < COMMENT: "--" (~["\n","\r"])* ("\n"|"\r"|"\r\n")? >
+}
+
+TOKEN : {
+ < LET: "let" >
+ | < ARROW: "->" >
+ | < EQ: "=" >
+ | < SEMI: ";" >
+ | < LPAREN: "(" >
+ | < RPAREN: ")" >
+ | < IDENT: (["a"-"z","A"-"Z","0"-"9","_"])+ >
+}
+
+LambdaProgram Program() :
+{
+ final List<Macro> macros = new ArrayList<Macro>();
+ Macro m;
+ Expression body;
+ Token eofToken;
+ Optional<SourceComment> eofComment;
+}
+{
+ (
+ m = Macro()
+ { macros.add(m); }
+ )*
+ body = Expression()
+ eofToken = <EOF>
+ { eofComment = extractComment(eofToken); }
+ {
+ final SourceSpan span;
+ if (!macros.isEmpty()) {
+ span = spanFromInts(macros.get(0).span().startLine(), macros.get(0).span().startColumn(), eofToken);
+ } else {
+ span = new SourceSpan(body.span().startLine(), body.span().startColumn(), eofToken.endLine, eofToken.endColumn);
+ }
+ if (eofComment.isPresent() && body.comment().isEmpty()) {
+ body = withComment(body, eofComment);
+ }
+ return new LambdaProgram(span, macros, body);
+ }
+}
+
+Macro Macro() :
+{
+ Token letToken;
+ Token name;
+ Token semiToken;
+ Expression value;
+ Optional<SourceComment> comment;
+}
+{
+ letToken = <LET>
+ { comment = extractComment(letToken); }
+ name = <IDENT>
+ <EQ>
+ value = Expression()
+ semiToken = <SEMI>
+ {
+ return new Macro(comment, spanFrom(letToken, semiToken), name.image, value);
+ }
+}
+
+Expression Expression() :
+{
+ Token paramToken;
+ Expression e;
+ Expression body;
+ Optional<SourceComment> comment;
+}
+{
+ (
+ LOOKAHEAD(<IDENT> <ARROW>)
+ paramToken = <IDENT>
+ { comment = extractComment(paramToken); }
+ <ARROW>
+ body = Expression()
+ {
+ e = new AbstractionExpression(comment, spanFrom(paramToken, body), paramToken.image, body);
+ }
+ |
+ e = Application()
+ )
+ {
+ return e;
+ }
+}
+
+Expression Application() :
+{
+ Expression e;
+ Expression arg;
+ Token rparen;
+}
+{
+ e = Atom()
+ (
+ <LPAREN>
+ arg = Expression()
+ rparen = <RPAREN>
+ {
+ e = new ApplicationExpression(Optional.empty(), spanFromInts(e.span().startLine(), e.span().startColumn(), rparen), e, arg);
+ }
+ )*
+ {
+ return e;
+ }
+}
+
+Expression Atom() :
+{
+ Token id;
+ Token lparen;
+ Token rparen;
+ Expression e;
+ Optional<SourceComment> comment;
+}
+{
+ id = <IDENT>
+ { comment = extractComment(id); }
+ {
+ return new IdentifierExpression(comment, spanFrom(id, id), id.image);
+ }
+|
+ lparen = <LPAREN>
+ { comment = extractComment(lparen); }
+ e = Expression()
+ rparen = <RPAREN>
+ {
+ final SourceSpan parenSpan = spanFrom(lparen, rparen);
+ if (comment.isPresent() && e.comment().isEmpty()) {
+ return withCommentAndSpan(e, comment, parenSpan);
+ }
+ return withSpan(e, parenSpan);
+ }
+}
+
diff --git a/core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt b/core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt
new file mode 100644
index 0000000..2482960
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt
@@ -0,0 +1,287 @@
+options {
+ STATIC = false;
+ LOOKAHEAD = 2;
+ UNICODE_INPUT = true;
+ NODE_PREFIX = "AST";
+}
+
+PARSER_BEGIN(LambdaParser)
+package coffee.liz.lambda.parser;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.SourceSpan;
+import coffee.liz.lambda.ast.SourceComment;
+
+public class LambdaParser {
+ public static void main(final String[] args) throws Exception {
+ final LambdaParser parser = new LambdaParser(System.in);
+ final LambdaProgram program = parser.Program();
+ System.out.println(program);
+ }
+
+ private static SourceSpan spanFrom(final Token start, final Token end) {
+ return new SourceSpan(start.beginLine, start.beginColumn, end.endLine, end.endColumn);
+ }
+
+ private static SourceSpan spanFrom(final Token start, final Expression end) {
+ return new SourceSpan(start.beginLine, start.beginColumn, end.span().endLine(), end.span().endColumn());
+ }
+
+ private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Token end) {
+ return new SourceSpan(startLine, startColumn, end.endLine, end.endColumn);
+ }
+
+ private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Expression end) {
+ return new SourceSpan(startLine, startColumn, end.span().endLine(), end.span().endColumn());
+ }
+
+ private static Expression withComment(final Expression expr, final Optional<SourceComment> comment) {
+ if (comment.isEmpty()) {
+ return expr;
+ }
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(comment, a.span(), a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(comment, a.span(), a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(comment, a.span(), a.name());
+ }
+ return expr;
+ }
+
+ private static Expression withSpan(final Expression expr, final SourceSpan span) {
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(a.comment(), span, a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(a.comment(), span, a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(a.comment(), span, a.name());
+ }
+ return expr;
+ }
+
+ private static Expression withCommentAndSpan(final Expression expr, final Optional<SourceComment> comment, final SourceSpan span) {
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(comment, span, a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(comment, span, a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(comment, span, a.name());
+ }
+ return expr;
+ }
+
+ private static Optional<SourceComment> extractComment(final Token t) {
+ if (t == null || t.specialToken == null) {
+ return Optional.empty();
+ }
+ Token st = t.specialToken;
+ while (st.specialToken != null) {
+ st = st.specialToken;
+ }
+ final Token firstComment = st;
+ final StringBuilder sb = new StringBuilder();
+ Token lastComment = st;
+ int lastEndLine = 0;
+ while (st != null) {
+ if (sb.length() > 0) {
+ final int blankLines = st.beginLine - lastEndLine - 1;
+ for (int i = 0; i < blankLines; i++) {
+ sb.append("\n");
+ }
+ sb.append("\n");
+ }
+ String image = st.image;
+ while (image.endsWith("\n") || image.endsWith("\r")) {
+ image = image.substring(0, image.length() - 1);
+ }
+ sb.append(image);
+ lastEndLine = st.endLine;
+ lastComment = st;
+ st = st.next;
+ }
+ final SourceSpan span = new SourceSpan(
+ firstComment.beginLine, firstComment.beginColumn,
+ lastComment.endLine, lastComment.endColumn
+ );
+ return Optional.of(new SourceComment(sb.toString(), span));
+ }
+}
+PARSER_END(LambdaParser)
+
+SKIP : {
+ " " | "\t" | "\r" | "\n"
+}
+
+SPECIAL_TOKEN : {
+ < COMMENT: "--" (~["\n","\r"])* ("\n"|"\r"|"\r\n")? >
+}
+
+TOKEN : {
+ < LET: "let" >
+ | < LAMBDA: "λ" | "\\" >
+ | < DOT: "." >
+ | < EQ: "=" >
+ | < SEMI: ";" >
+ | < LPAREN: "(" >
+ | < RPAREN: ")" >
+ | < IDENT: (["a"-"z","A"-"Z","0"-"9","_"])+ >
+}
+
+LambdaProgram Program() :
+{
+ final List<Macro> macros = new ArrayList<Macro>();
+ Macro m;
+ Expression body;
+ Token eofToken;
+ Optional<SourceComment> eofComment;
+}
+{
+ (
+ m = Macro()
+ { macros.add(m); }
+ )*
+ body = Expression()
+ eofToken = <EOF>
+ { eofComment = extractComment(eofToken); }
+ {
+ final SourceSpan span;
+ if (!macros.isEmpty()) {
+ span = spanFromInts(macros.get(0).span().startLine(), macros.get(0).span().startColumn(), eofToken);
+ } else {
+ span = new SourceSpan(body.span().startLine(), body.span().startColumn(), eofToken.endLine, eofToken.endColumn);
+ }
+ if (eofComment.isPresent() && body.comment().isEmpty()) {
+ body = withComment(body, eofComment);
+ }
+ return new LambdaProgram(span, macros, body);
+ }
+}
+
+Macro Macro() :
+{
+ Token letToken;
+ Token name;
+ Token semiToken;
+ Expression value;
+ Optional<SourceComment> comment;
+}
+{
+ letToken = <LET>
+ { comment = extractComment(letToken); }
+ name = <IDENT>
+ <EQ>
+ value = Expression()
+ semiToken = <SEMI>
+ {
+ return new Macro(comment, spanFrom(letToken, semiToken), name.image, value);
+ }
+}
+
+Expression Expression() :
+{
+ Expression e;
+}
+{
+ (
+ e = Lambda()
+ |
+ e = Application()
+ )
+ {
+ return e;
+ }
+}
+
+Expression Lambda() :
+{
+ Token lambdaToken;
+ Token param;
+ Expression body;
+ Optional<SourceComment> comment;
+}
+{
+ lambdaToken = <LAMBDA>
+ { comment = extractComment(lambdaToken); }
+ param = <IDENT>
+ <DOT>
+ body = Expression()
+ {
+ return new AbstractionExpression(comment, spanFrom(lambdaToken, body), param.image, body);
+ }
+}
+
+Expression Application() :
+{
+ Expression e;
+ Expression arg;
+}
+{
+ e = Atom()
+ (
+ arg = Atom()
+ {
+ e = new ApplicationExpression(Optional.empty(), spanFromInts(e.span().startLine(), e.span().startColumn(), arg), e, arg);
+ }
+ )*
+ {
+ return e;
+ }
+}
+
+Expression Atom() :
+{
+ Token id;
+ Token lparen;
+ Token rparen;
+ Expression e;
+ Optional<SourceComment> comment;
+}
+{
+ id = <IDENT>
+ { comment = extractComment(id); }
+ {
+ return new IdentifierExpression(comment, spanFrom(id, id), id.image);
+ }
+|
+ lparen = <LPAREN>
+ { comment = extractComment(lparen); }
+ e = Expression()
+ rparen = <RPAREN>
+ {
+ final SourceSpan parenSpan = spanFrom(lparen, rparen);
+ if (comment.isPresent() && e.comment().isEmpty()) {
+ return withCommentAndSpan(e, comment, parenSpan);
+ }
+ return withSpan(e, parenSpan);
+ }
+|
+ e = Lambda()
+ {
+ return e;
+ }
+}
+
diff --git a/core/src/main/resources/log4j2.xml b/core/src/main/resources/log4j2.xml
new file mode 100644
index 0000000..c52aaea
--- /dev/null
+++ b/core/src/main/resources/log4j2.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Configuration status="WARN">
+ <Appenders>
+ <Console name="Console" target="SYSTEM_OUT">
+ <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
+ </Console>
+ </Appenders>
+ <Loggers>
+ <Root level="info">
+ <AppenderRef ref="Console"/>
+ </Root>
+ </Loggers>
+</Configuration>
diff --git a/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystemTest.java b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystemTest.java
new file mode 100644
index 0000000..3743f3e
--- /dev/null
+++ b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystemTest.java
@@ -0,0 +1,188 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import coffee.liz.abstractionengine.grid.component.GridCollidable;
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior;
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior.CollisionBehaviorType;
+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.Entity;
+import coffee.liz.ecs.model.World;
+
+import lombok.Getter;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+final class GridCollisionPropagatationSystemTest {
+ private static final Duration FRAME = Duration.ZERO;
+ private static final GridCollidable WALL_COLLIDABLE = (me, them) -> CollisionBehavior.builder()
+ .collisionBehaviorType(CollisionBehaviorType.WALL).priority(10).build();
+ private static final GridCollidable PROPAGATE_COLLIDABLE = (me, them) -> CollisionBehavior.builder()
+ .collisionBehaviorType(CollisionBehaviorType.PROPAGATE).priority(0).build();
+
+ @Getter
+ private static class SwallowCollidable implements GridCollidable {
+ private final Set<Entity> swallowed = new HashSet<>();
+ @Override
+ public <T> void onSwallow(final Entity them, final World<T> world) {
+ swallowed.add(them);
+ }
+
+ @Override
+ public CollisionBehavior getCollisionBehaviorBetween(final Entity me, final Entity them) {
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(0).build();
+ }
+ }
+
+ @Test
+ public void testPrioritization() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+ when(indexSystem.inBounds(any())).thenReturn(true);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.EAST))
+ .add(new GridPosition(Vec2i.ZERO));
+ final Entity toPropagate = Entity.builder().id(2).build().add(PROPAGATE_COLLIDABLE)
+ .add(new GridPosition(Vec2i.EAST));
+ final Entity wall = Entity.builder().id(3).build().add(WALL_COLLIDABLE).add(new GridPosition(Vec2i.EAST));
+
+ // Propagation takes priority because priority(0) is lower
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+ when(indexSystem.entitiesAt(Vec2i.EAST)).thenReturn(List.of(toPropagate, wall));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, mock(GridInputState.class), FRAME);
+
+ assertEquals(Vec2i.EAST, pusher.get(GridMomentum.class).getVelocity());
+ assertEquals(Vec2i.EAST, toPropagate.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testWallCollisionHaltsRayMomentum() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+ when(indexSystem.inBounds(any())).thenReturn(true);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.EAST))
+ .add(new GridPosition(Vec2i.ZERO));
+ final Entity toPropagate = Entity.builder().id(2).build().add(PROPAGATE_COLLIDABLE)
+ .add(new GridMomentum(Vec2i.EAST)).add(new GridPosition(Vec2i.EAST));
+ final Entity wall = Entity.builder().id(3).build().add(WALL_COLLIDABLE)
+ .add(new GridPosition(Vec2i.EAST.scale(2, 0)));
+
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher, toPropagate));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+ when(indexSystem.entitiesAt(Vec2i.EAST)).thenReturn(List.of(toPropagate));
+ when(indexSystem.entitiesAt(Vec2i.EAST.scale(2, 0))).thenReturn(List.of(wall));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, GridInputState.builder().build(), FRAME);
+
+ assertEquals(Vec2i.ZERO, pusher.get(GridMomentum.class).getVelocity());
+ assertEquals(Vec2i.ZERO, toPropagate.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testGoingOutOfBoundsHaltsMomentum() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+
+ when(indexSystem.inBounds(any())).thenReturn(false);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.EAST))
+ .add(new GridPosition(Vec2i.ZERO));
+
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, GridInputState.builder().build(), FRAME);
+
+ assertEquals(Vec2i.ZERO, pusher.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testZeroVelocity() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+
+ when(indexSystem.inBounds(any())).thenReturn(true);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.ZERO))
+ .add(new GridPosition(Vec2i.ZERO));
+
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, GridInputState.builder().build(), FRAME);
+
+ assertEquals(Vec2i.ZERO, pusher.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testSwallowInteraction() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+
+ when(indexSystem.inBounds(any())).thenReturn(true);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final SwallowCollidable swallowCollidable = new SwallowCollidable();
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.EAST))
+ .add(new GridPosition(Vec2i.ZERO));
+
+ final Entity swallower = Entity.builder().id(2).build().add(swallowCollidable)
+ .add(new GridPosition(Vec2i.EAST));
+
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+ when(indexSystem.entitiesAt(Vec2i.EAST)).thenReturn(List.of(swallower));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, GridInputState.builder().build(), FRAME);
+
+ assertEquals(Vec2i.EAST, pusher.get(GridMomentum.class).getVelocity());
+ assertFalse(swallower.has(GridMomentum.class));
+
+ assertEquals(swallowCollidable.getSwallowed(), Set.of(pusher));
+ }
+
+ @Test
+ public void testDependencies() {
+ assertEquals(Set.of(GridMovementSystem.class, GridIndexSystem.class),
+ new GridCollisionPropagatationSystem().getDependencies());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static World<GridInputState> mockWorld() {
+ return (World<GridInputState>) mock(World.class);
+ }
+}
diff --git a/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridIndexSystemTest.java b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridIndexSystemTest.java
new file mode 100644
index 0000000..d9afde5
--- /dev/null
+++ b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridIndexSystemTest.java
@@ -0,0 +1,70 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.Set;
+
+class GridIndexSystemTest {
+ @Test
+ public void testUpdateIndexesEntitiesIntoGridSlots() {
+ final GridIndexSystem system = new GridIndexSystem(new Vec2i(4, 4));
+ final World<GridInputState> world = mockWorld();
+ final Entity alpha = Entity.builder().id(11).build();
+ alpha.add(new GridPosition(new Vec2i(1, 2)));
+ final Entity beta = Entity.builder().id(12).build();
+ beta.add(new GridPosition(new Vec2i(0, 0)));
+ when(world.query(Set.of(GridPosition.class))).thenReturn(Set.of(alpha, beta));
+
+ system.update(world, GridInputState.builder().movement(Vec2i.NORTH).build(), Duration.ZERO);
+
+ assertTrue(system.entitiesAt(new Vec2i(1, 2)).contains(alpha));
+ assertTrue(system.entitiesAt(new Vec2i(0, 0)).contains(beta));
+ assertTrue(system.entitiesAt(new Vec2i(3, 3)).isEmpty());
+ }
+
+ @Test
+ public void testUpdateClearsPreviousIndexesBeforeRebuilding() {
+ final GridIndexSystem system = new GridIndexSystem(new Vec2i(2, 2));
+ final World<GridInputState> world = mockWorld();
+ final Entity moving = Entity.builder().id(77).build();
+ moving.add(new GridPosition(Vec2i.ZERO));
+ when(world.query(Set.of(GridPosition.class))).thenReturn(Set.of(moving)).thenReturn(Set.of());
+
+ system.update(world, GridInputState.builder().movement(Vec2i.EAST).build(), Duration.ZERO);
+ assertTrue(system.entitiesAt(Vec2i.ZERO).contains(moving));
+
+ system.update(world, GridInputState.builder().movement(Vec2i.NORTH).build(), Duration.ZERO);
+ assertTrue(system.entitiesAt(Vec2i.ZERO).isEmpty());
+ }
+
+ @Test
+ public void testEntitiesAtReturnsEmptySetForOutOfBoundsQuery() {
+ final GridIndexSystem system = new GridIndexSystem(new Vec2i(2, 2));
+
+ assertEquals(Set.of(), system.entitiesAt(new Vec2i(-1, 0)));
+ assertEquals(Set.of(), system.entitiesAt(new Vec2i(2, 1)));
+ assertEquals(Set.of(), system.entitiesAt(new Vec2i(1, 2)));
+ }
+
+ @Test
+ public void testDependencies() {
+ assertEquals(Set.of(), new GridIndexSystem(Vec2i.ZERO).getDependencies());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static World<GridInputState> mockWorld() {
+ return (World<GridInputState>) Mockito.mock(World.class);
+ }
+}
diff --git a/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridMovementSystemTest.java b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridMovementSystemTest.java
new file mode 100644
index 0000000..8bd8ff3
--- /dev/null
+++ b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridMovementSystemTest.java
@@ -0,0 +1,48 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+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.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.Set;
+
+final class GridMovementSystemTest {
+ @Test
+ public void testUpdateAssignsMomentumToControllableEntities() {
+ final World<GridInputState> world = mockWorld();
+ final Entity subject = Entity.builder().id(1).build();
+ subject.add(new GridControllable());
+ subject.add(new GridPosition(Vec2i.ZERO));
+ final Set<Entity> controllableEntities = Set.of(subject);
+ when(world.query(Set.of(GridControllable.class, GridPosition.class))).thenReturn(controllableEntities);
+
+ final GridInputState inputState = GridInputState.builder().movement(Vec2i.SOUTH).build();
+ final GridMovementSystem system = new GridMovementSystem();
+
+ system.update(world, inputState, Duration.ofMillis(16));
+
+ final GridMomentum appliedMomentum = subject.get(GridMomentum.class);
+ assertEquals(Vec2i.SOUTH, appliedMomentum.getVelocity());
+ }
+
+ @Test
+ public void testDependencies() {
+ assertEquals(Set.of(), new GridMovementSystem().getDependencies());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static World<GridInputState> mockWorld() {
+ return (World<GridInputState>) Mockito.mock(World.class);
+ }
+}
diff --git a/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystemTest.java b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystemTest.java
new file mode 100644
index 0000000..c3bb01e
--- /dev/null
+++ b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystemTest.java
@@ -0,0 +1,46 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+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.World;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.Set;
+
+final class GridPhysicsSystemTest {
+ @Test
+ public void testUpdateMovesEntitiesByMomentumAndResetsVelocity() {
+ final World<GridInputState> world = mockWorld();
+ final Entity body = Entity.builder().id(3).build();
+ body.add(new GridPosition(Vec2i.ZERO));
+ body.add(new GridMomentum(Vec2i.EAST));
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class))).thenReturn(Set.of(body));
+
+ final GridPhysicsSystem system = new GridPhysicsSystem();
+ system.update(world, GridInputState.builder().movement(Vec2i.NORTH).build(), Duration.ZERO);
+
+ final Vec2<Integer> newPosition = body.get(GridPosition.class).getPosition();
+ assertEquals(Vec2i.EAST, newPosition);
+ assertEquals(Vec2i.ZERO, body.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testDependencies() {
+ assertEquals(Set.of(GridCollisionPropagatationSystem.class), new GridPhysicsSystem().getDependencies());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static World<GridInputState> mockWorld() {
+ return (World<GridInputState>) Mockito.mock(World.class);
+ }
+}
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..2f948d0
--- /dev/null
+++ b/core/src/test/java/coffee/liz/ecs/DAGWorldTest.java
@@ -0,0 +1,164 @@
+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 lombok.RequiredArgsConstructor;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+final class DAGWorldTest {
+ @Test
+ public void queryReturnsEntitiesMatchingAllRequestedComponents() {
+ final DAGWorld<String> world = new DAGWorld<>(Map.of());
+ final Entity matching = world.createEntity();
+ matching.add(new PositionComponent());
+ matching.add(new VelocityComponent());
+ final Entity partial = world.createEntity();
+ partial.add(new PositionComponent());
+ final Entity nonMatching = world.createEntity();
+ nonMatching.add(new VelocityComponent());
+
+ world.update("state", Duration.ZERO);
+
+ final Set<Entity> results = world.query(Set.of(PositionComponent.class, VelocityComponent.class));
+
+ assertEquals(Set.of(matching), results);
+ }
+
+ @Test
+ public void queryWithoutComponentsReturnsAllEntities() {
+ final DAGWorld<String> world = new DAGWorld<>(Map.of());
+ final Entity entityOne = world.createEntity();
+ final Entity entityTwo = world.createEntity();
+
+ final Set<Entity> results = world.query(List.<Class<? extends Component>>of());
+
+ assertEquals(Set.of(entityOne, entityTwo), results);
+ }
+
+ @Test
+ public void updateExecutesSystemsInTopologicalOrder() {
+ final CopyOnWriteArrayList<String> executionLog = new CopyOnWriteArrayList<>();
+
+ final DAGWorld<String> world = new DAGWorld<>(Map.of(SystemC.class, new SystemC(executionLog), SystemA.class,
+ new SystemA(executionLog), SystemB.class, new SystemB(executionLog)));
+ world.update("state", Duration.ZERO);
+
+ assertEquals(List.of("A", "B", "C"), executionLog);
+ }
+
+ @Test
+ public void updateRefreshesComponentCacheAfterEntityMutations() {
+ final DAGWorld<String> world = new DAGWorld<>(Map.of());
+ final Entity subject = world.createEntity();
+
+ world.update("state", Duration.ZERO);
+ assertTrue(world.query(Set.of(PositionComponent.class)).isEmpty());
+
+ subject.add(new PositionComponent());
+ world.update("state", Duration.ZERO);
+ assertEquals(1, world.query(Set.of(PositionComponent.class)).size());
+
+ subject.remove(PositionComponent.class);
+ world.update("state", Duration.ZERO);
+ assertTrue(world.query(Set.of(PositionComponent.class)).isEmpty());
+ }
+
+ @Test
+ public void circularDependencyDetectionThrowsIllegalStateException() {
+ final Map<Class<? extends System<String>>, System<String>> systems = new LinkedHashMap<>();
+ final SystemCycleA systemA = new SystemCycleA();
+ final SystemCycleB systemB = new SystemCycleB();
+ systems.put(SystemCycleA.class, systemA);
+ systems.put(SystemCycleB.class, systemB);
+
+ assertThrows(IllegalStateException.class, () -> new DAGWorld<>(systems));
+ }
+
+ private static final class PositionComponent implements Component {
+ }
+
+ private static final class VelocityComponent implements Component {
+ }
+
+ @RequiredArgsConstructor
+ private abstract static class RecordingSystem implements System<String> {
+ private final List<String> log;
+ private final String label;
+
+ @Override
+ public final void update(final World<String> world, final String state, final Duration duration) {
+ log.add(label);
+ }
+ }
+
+ private static final class SystemA extends RecordingSystem {
+ private SystemA(final List<String> log) {
+ super(log, "A");
+ }
+
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return List.of();
+ }
+ }
+
+ private static final class SystemB extends RecordingSystem {
+ private SystemB(final List<String> log) {
+ super(log, "B");
+ }
+
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return Set.of(SystemA.class);
+ }
+ }
+
+ private static final class SystemC extends RecordingSystem {
+ private SystemC(final List<String> log) {
+ super(log, "C");
+ }
+
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return Set.of(SystemB.class);
+ }
+ }
+
+ private static final class SystemCycleA implements System<String> {
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return Set.of(SystemCycleB.class);
+ }
+
+ @Override
+ public void update(final World<String> world, final String state, final Duration duration) {
+ }
+ }
+
+ private static final class SystemCycleB implements System<String> {
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return Set.of(SystemCycleA.class);
+ }
+
+ @Override
+ public void update(final World<String> world, final String state, final Duration duration) {
+ }
+ }
+}
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..c9ce59a
--- /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<Class<? extends Component>> 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<Arguments> 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<? extends Component> getKey() {
+ return GammaComponent.class;
+ }
+ }
+
+ private class GammaKeyedComponent extends GammaComponent {
+ }
+
+ private record ContextualComponent(int ownerId) implements Component {
+ }
+}
diff --git a/core/src/test/java/coffee/liz/lambda/eval/InterpreterTest.java b/core/src/test/java/coffee/liz/lambda/eval/InterpreterTest.java
new file mode 100644
index 0000000..4b63782
--- /dev/null
+++ b/core/src/test/java/coffee/liz/lambda/eval/InterpreterTest.java
@@ -0,0 +1,103 @@
+package coffee.liz.lambda.eval;
+
+import coffee.liz.lambda.LambdaDriver;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.SourceCode;
+import coffee.liz.lambda.bind.Tick;
+import coffee.liz.lambda.bind.ToChurch;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class InterpreterTest {
+
+ @Test
+ public void identity() {
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow("x -> x"));
+
+ final Value.Closure closure = assertInstanceOf(Value.Closure.class, result);
+ assertEquals("x", closure.parameter());
+ assertInstanceOf(IdentifierExpression.class, closure.body());
+ }
+
+ @Test
+ public void identityApplication() {
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow("(x -> x)(y)"));
+
+ assertEquals(new Value.Free("y"), result);
+ }
+
+ @Test
+ public void nestedApplication() {
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow("(f -> g -> x -> f(g)(x))(x -> x)(y -> y)(x)"));
+
+ assertEquals(new Value.Free("x"), result);
+ }
+
+ @Test
+ public void cons() {
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow("""
+ let pair = a -> b -> f -> f(a)(b);
+ let second = x -> y -> y;
+ pair(x)(y)(second)
+ """));
+
+ assertEquals(new Value.Free("y"), result);
+ }
+
+ @Test
+ public void fibonacci() {
+ final String source = """
+ let true = t -> f -> t;
+ let false = t -> f -> f;
+
+ let pair = x -> y -> f -> f(x)(y);
+ let fst = p -> p(x -> y -> x);
+ let snd = p -> p(x -> y -> y);
+
+ let 0 = f -> x -> x;
+ let 1 = f -> x -> f(x);
+
+ let succ = n -> f -> x -> f(n(f)(x));
+ let plus = m -> n -> f -> x -> m(f)(n(f)(x));
+ let next = p -> pair(snd(p))(succ(snd(p)));
+ let pred = n -> fst(n(next)(pair(0)(0)));
+
+ let iszero = n -> n(x -> false)(true);
+ let isone = n -> iszero(pred(n));
+
+ let y = f -> (x -> f(x(x)))(x -> f(x(x)));
+
+ let fib = y(fib -> n ->
+ iszero(n) (0)
+ (isone(n) (1)
+ (plus
+ (fib(pred(n)))
+ (fib(pred(pred(n)))))));
+
+ fib(ToChurch(13))(Tick)(dummy)
+ """;
+
+ final Tick ticker = new Tick();
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow(source), List.of(ticker, new ToChurch()));
+
+ assertEquals(new Value.Free("dummy"), result);
+ assertEquals(233, ticker.getCounter().get());
+ }
+
+ @Test
+ public void omegaCombinatorThrowsDepthExceeded() {
+ final LambdaProgram program = LambdaDriver.parse(SourceCode.ofArrow("(x -> x(x))(x -> x(x))"));
+ final Environment env = Environment.from(program.macros(), List.of());
+
+ final EvaluationDepthExceededException exception = assertThrows(EvaluationDepthExceededException.class,
+ () -> NormalOrderEvaluator.evaluate(program.expression(), env, 100));
+
+ assertEquals(100, exception.getMaxDepth());
+ }
+}
diff --git a/core/src/test/java/coffee/liz/lambda/eval/ThunkTest.java b/core/src/test/java/coffee/liz/lambda/eval/ThunkTest.java
new file mode 100644
index 0000000..2a1d5e3
--- /dev/null
+++ b/core/src/test/java/coffee/liz/lambda/eval/ThunkTest.java
@@ -0,0 +1,38 @@
+package coffee.liz.lambda.eval;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+public class ThunkTest {
+ @Test
+ public void testThunkNonNull() {
+ final AtomicInteger invok = new AtomicInteger(0);
+ final Supplier<Integer> i = () -> {
+ invok.incrementAndGet();
+ return invok.get();
+ };
+ final Thunk<Integer> thunk = new Thunk<>(i);
+ Assertions.assertEquals(1, thunk.get());
+ Assertions.assertEquals(1, thunk.get());
+ Assertions.assertEquals(1, thunk.get());
+ Assertions.assertEquals(1, invok.get());
+ }
+
+ @Test
+ public void testThunkNull() {
+ final AtomicInteger invok = new AtomicInteger(0);
+ final Supplier<Integer> i = () -> {
+ invok.incrementAndGet();
+ return null;
+ };
+ final Thunk<Integer> thunk = new Thunk<>(i);
+ Assertions.assertNull(thunk.get());
+ Assertions.assertNull(thunk.get());
+ Assertions.assertNull(thunk.get());
+ Assertions.assertEquals(1, invok.get());
+ }
+}
diff --git a/core/src/test/java/coffee/liz/lambda/format/FormatterTest.java b/core/src/test/java/coffee/liz/lambda/format/FormatterTest.java
new file mode 100644
index 0000000..111855f
--- /dev/null
+++ b/core/src/test/java/coffee/liz/lambda/format/FormatterTest.java
@@ -0,0 +1,53 @@
+package coffee.liz.lambda.format;
+
+import coffee.liz.lambda.LambdaDriver;
+import coffee.liz.lambda.ast.SourceCode;
+import coffee.liz.lambda.ast.SourceCode.Syntax;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class FormatterTest {
+ @ParameterizedTest
+ @MethodSource("provideRoundTrip")
+ public void testRoundTrip(final String lambda, final String arrow) {
+ final String formattedLambda = format(SourceCode.ofArrow(arrow), Syntax.LAMBDA);
+ final String formattedArrow = format(SourceCode.ofLambda(lambda), Syntax.ARROW);
+ assertEquals(lambda, formattedLambda);
+ assertEquals(arrow, formattedArrow);
+ }
+
+ public static Stream<Arguments> provideRoundTrip() {
+ return Stream.of(Arguments.of("λx.λy.x", "x -> y -> x"), Arguments.of("(λx.x) y", "(x -> x)(y)"),
+ Arguments.of("f x y z", "f(x)(y)(z)"), Arguments.of("f x y z -- Comment!", "f(x)(y)(z) -- Comment!"),
+ Arguments.of("""
+ let id = λx.x;
+ let const = λx.λy.x; -- Inline comment!
+
+ -- Test comment
+ -- Another comment
+ id""", """
+ let id = x -> x;
+ let const = x -> y -> x; -- Inline comment!
+
+ -- Test comment
+ -- Another comment
+ id"""), Arguments.of("""
+ -- The identity function
+ let id = λx.x;
+
+ id""", """
+ -- The identity function
+ let id = x -> x;
+
+ id"""));
+ }
+
+ private static String format(final SourceCode code, final Syntax syntax) {
+ return Formatter.emit(LambdaDriver.parse(code), syntax);
+ }
+}
diff --git a/core/src/test/java/coffee/liz/lambda/parser/ParserTest.java b/core/src/test/java/coffee/liz/lambda/parser/ParserTest.java
new file mode 100644
index 0000000..0158003
--- /dev/null
+++ b/core/src/test/java/coffee/liz/lambda/parser/ParserTest.java
@@ -0,0 +1,178 @@
+package coffee.liz.lambda.parser;
+
+import coffee.liz.lambda.LambdaDriver;
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.SourceCode;
+import coffee.liz.lambda.ast.SourceComment;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+class ParserTest {
+
+ @Test
+ public void testTrivial() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("λx.x"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("x -> x"));
+
+ assertStructurallyEqual(lambda, arrow);
+ assertEquals(0, lambda.macros().size());
+ assertInstanceOf(AbstractionExpression.class, lambda.expression());
+ assertEquals("x", ((AbstractionExpression) lambda.expression()).parameter());
+ }
+
+ @Test
+ public void testApplication() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("(λx.x) y"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("(x -> x)(y)"));
+
+ assertStructurallyEqual(lambda, arrow);
+
+ final ApplicationExpression app = (ApplicationExpression) lambda.expression();
+ assertInstanceOf(AbstractionExpression.class, app.applicable());
+ assertInstanceOf(IdentifierExpression.class, app.argument());
+ }
+
+ @Test
+ public void testChainedLambdas() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("λx.λy.λz.x"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("x -> y -> z -> x"));
+
+ assertStructurallyEqual(lambda, arrow);
+
+ final AbstractionExpression outer = (AbstractionExpression) lambda.expression();
+ assertEquals("x", outer.parameter());
+ final AbstractionExpression middle = (AbstractionExpression) outer.body();
+ assertEquals("y", middle.parameter());
+ final AbstractionExpression inner = (AbstractionExpression) middle.body();
+ assertEquals("z", inner.parameter());
+ }
+
+ @Test
+ public void testChainedApplication() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("((f x) y) z"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("f(x)(y)(z)"));
+
+ assertStructurallyEqual(lambda, arrow);
+
+ final ApplicationExpression app1 = (ApplicationExpression) lambda.expression();
+ assertEquals("z", ((IdentifierExpression) app1.argument()).name());
+ final ApplicationExpression app2 = (ApplicationExpression) app1.applicable();
+ assertEquals("y", ((IdentifierExpression) app2.argument()).name());
+ final ApplicationExpression app3 = (ApplicationExpression) app2.applicable();
+ assertEquals("f", ((IdentifierExpression) app3.applicable()).name());
+ assertEquals("x", ((IdentifierExpression) app3.argument()).name());
+ }
+
+ @Test
+ public void testMacros() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("""
+ let id = λx.x;
+ id
+ """));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("""
+ let id = x -> x;
+ id
+ """));
+
+ assertStructurallyEqual(lambda, arrow);
+ assertEquals(1, lambda.macros().size());
+ assertEquals("id", lambda.macros().getFirst().name());
+ assertInstanceOf(IdentifierExpression.class, lambda.expression());
+ }
+
+ @Test
+ public void testLineComments() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("""
+ -- The identity function
+ let id = λx.x; -- returns its argument
+ id -- use it
+ """));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("""
+ -- The identity function
+ let id = x -> x; -- returns its argument
+ id -- use it
+ """));
+
+ assertStructurallyEqual(lambda, arrow);
+ assertEquals(1, lambda.macros().size());
+ assertEquals("id", lambda.macros().getFirst().name());
+ }
+
+ @Test
+ public void testComplexProgram() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("""
+ let zero = λf.λx.x;
+ let one = λf.λx.f x;
+ let succ = λn.λf.λx.f (n f x);
+ let add = λm.λn.λf.λx.m f (n f x);
+
+ succ (add one zero)
+ """));
+
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("""
+ let zero = f -> x -> x;
+ let one = f -> x -> f(x);
+ let succ = n -> f -> x -> f(n(f)(x));
+ let add = m -> n -> f -> x -> m(f)(n(f)(x));
+
+ succ(add(one)(zero))
+ """));
+
+ assertStructurallyEqual(lambda, arrow);
+
+ assertEquals(4, lambda.macros().size());
+ assertEquals("zero", lambda.macros().get(0).name());
+ assertEquals("one", lambda.macros().get(1).name());
+ assertEquals("succ", lambda.macros().get(2).name());
+ assertEquals("add", lambda.macros().get(3).name());
+ }
+
+ @Test
+ public void testOmegaCombinator() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("(λx.x x)(λx.x x)"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("(x -> x(x))(x -> x(x))"));
+
+ assertStructurallyEqual(lambda, arrow);
+ }
+
+ private static void assertStructurallyEqual(final LambdaProgram expected, final LambdaProgram actual) {
+ assertEquals(expected.macros().size(), actual.macros().size(), "Macro count mismatch");
+ for (int i = 0; i < expected.macros().size(); i++) {
+ assertStructurallyEqual(expected.macros().get(i), actual.macros().get(i));
+ }
+ assertStructurallyEqual(expected.expression(), actual.expression());
+ }
+
+ private static void assertStructurallyEqual(final Macro expected, final Macro actual) {
+ assertEquals(expected.name(), actual.name(), "Macro name mismatch");
+ assertEquals(expected.comment().map(SourceComment::text), actual.comment().map(SourceComment::text),
+ "Macro comment mismatch");
+ assertStructurallyEqual(expected.expression(), actual.expression());
+ }
+
+ private static void assertStructurallyEqual(final Expression expected, final Expression actual) {
+ assertEquals(expected.getClass(), actual.getClass(), "Expression type mismatch");
+ assertEquals(expected.comment().map(SourceComment::text), actual.comment().map(SourceComment::text),
+ "Expression comment mismatch");
+
+ switch (expected) {
+ case IdentifierExpression e ->
+ assertEquals(e.name(), ((IdentifierExpression) actual).name(), "Identifier name mismatch");
+ case AbstractionExpression e -> {
+ assertEquals(e.parameter(), ((AbstractionExpression) actual).parameter(), "Parameter mismatch");
+ assertStructurallyEqual(e.body(), ((AbstractionExpression) actual).body());
+ }
+ case ApplicationExpression e -> {
+ assertStructurallyEqual(e.applicable(), ((ApplicationExpression) actual).applicable());
+ assertStructurallyEqual(e.argument(), ((ApplicationExpression) actual).argument());
+ }
+ }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..09e49b0
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,20 @@
+# This doesn't need to be false, and some projects may be able to take advantage of setting daemon to true.
+# We set it to false by default in order to avoid too many daemons from being created and persisting; each needs RAM.
+org.gradle.daemon=false
+# Sets starting memory usage to 512MB, maximum memory usage to 1GB, and tries to set as much to use Unicode as we can.
+org.gradle.jvmargs=-Xms512M -Xmx1G -Dfile.encoding=UTF-8 -Dconsole.encoding=UTF-8
+# "Configure on-demand" must be false because it breaks projects that have Android modules. The default is also false.
+org.gradle.configureondemand=false
+# The logging level determines which messages get shown about how Gradle itself is working, such as if build.gradle
+# files are fully future-proof (which they never are, because Gradle constantly deprecates working APIs).
+# You can change 'quiet' below to 'lifecycle' to use Gradle's default behavior, which shows some confusing messages.
+# You could instead change 'quiet' below to 'info' to see info that's important mainly while debugging build files.
+# Note that if you want to use Gradle Build Scans, you should set the below logging level to 'lifecycle', otherwise
+# the link to the scan won't get shown at all.
+# Documented at: https://docs.gradle.org/current/userguide/command_line_interface.html#sec:command_line_logging
+org.gradle.logging.level=lifecycle
+visUiVersion=1.5.7
+graalHelperVersion=2.0.1
+enableGraalNative=false
+gdxVersion=1.14.0
+projectVersion=1.0.0
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..67ed422
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73c462e34475aeb6509ab7ba3eda218f/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/54001d0b636ad500b432d20ef3d580d0/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73c462e34475aeb6509ab7ba3eda218f/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/54001d0b636ad500b432d20ef3d580d0/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29c55e6bad8a0049163f0184625cecd9/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/3ac7a5361c25c0b23d933f44bdb0abd9/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73c462e34475aeb6509ab7ba3eda218f/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/54001d0b636ad500b432d20ef3d580d0/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/28937bb8a7f83f57de92429a9a11c04e/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/52fa104f4f641439587f75dd68b31bc2/redirect
+toolchainVersion=17
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..f8e1ee3
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..23449a2
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..adff685
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100755
index 0000000..c4bdd3a
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/lwjgl3/build.gradle b/lwjgl3/build.gradle
new file mode 100644
index 0000000..295b01b
--- /dev/null
+++ b/lwjgl3/build.gradle
@@ -0,0 +1,193 @@
+
+buildscript {
+ repositories {
+ gradlePluginPortal()
+ }
+ dependencies {
+ classpath "io.github.fourlastor:construo:2.1.0"
+ if(enableGraalNative == 'true') {
+ classpath "org.graalvm.buildtools.native:org.graalvm.buildtools.native.gradle.plugin:0.9.28"
+ }
+ }
+}
+plugins {
+ id "application"
+}
+apply plugin: 'io.github.fourlastor.construo'
+
+
+import io.github.fourlastor.construo.Target
+
+sourceSets.main.resources.srcDirs += [ rootProject.file('assets').path ]
+application.mainClass = 'coffee.liz.abstractionengine.lwjgl3.Lwjgl3Launcher'
+eclipse.project.name = appName + '-lwjgl3'
+java.sourceCompatibility = 24
+java.targetCompatibility = 24
+if (JavaVersion.current().isJava9Compatible()) {
+ compileJava.options.release.set(24)
+}
+
+dependencies {
+ implementation "com.badlogicgames.gdx:gdx-backend-lwjgl3:$gdxVersion"
+ implementation "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-desktop"
+ implementation "com.badlogicgames.gdx:gdx-lwjgl3-angle:$gdxVersion"
+ implementation "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop"
+ implementation project(':core')
+
+ if(enableGraalNative == 'true') {
+ implementation "io.github.berstanio:gdx-svmhelper-backend-lwjgl3:$graalHelperVersion"
+ implementation "io.github.berstanio:gdx-svmhelper-extension-freetype:$graalHelperVersion"
+
+ }
+}
+
+def os = System.properties['os.name'].toLowerCase(Locale.ROOT)
+
+run {
+ workingDir = rootProject.file('assets').path
+// You can uncomment the next line if your IDE claims a build failure even when the app closed properly.
+ //setIgnoreExitValue(true)
+
+ if (os.contains('mac')) jvmArgs += "-XstartOnFirstThread"
+}
+
+// Run with JVM debugger listening on port 5005
+tasks.register('runDebug', JavaExec) {
+ group = 'application'
+ description = 'Run the application with the JVM debugger listening on 5005'
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = application.mainClass
+ workingDir = rootProject.file('assets').path
+ if (os.contains('mac')) jvmArgs += "-XstartOnFirstThread"
+ jvmArgs += "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005"
+}
+
+jar {
+// sets the name of the .jar file this produces to the name of the game or app, with the version after.
+ archiveFileName.set("${appName}-${projectVersion}.jar")
+// the duplicatesStrategy matters starting in Gradle 7.0; this setting works.
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+ dependsOn configurations.runtimeClasspath
+ from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } }
+// these "exclude" lines remove some unnecessary duplicate files in the output JAR.
+ exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA')
+ dependencies {
+ exclude('META-INF/INDEX.LIST', 'META-INF/maven/**')
+ }
+// setting the manifest makes the JAR runnable.
+// enabling native access helps avoid a warning when Java 24 or later runs the JAR.
+ manifest {
+ attributes 'Main-Class': application.mainClass, 'Enable-Native-Access': 'ALL-UNNAMED'
+ }
+// this last step may help on some OSes that need extra instruction to make runnable JARs.
+ doLast {
+ file(archiveFile).setExecutable(true, false)
+ }
+}
+
+// Builds a JAR that only includes the files needed to run on macOS, not Windows or Linux.
+// The file size for a Mac-only JAR is about 7MB smaller than a cross-platform JAR.
+tasks.register("jarMac") {
+ dependsOn("jar")
+ group("build")
+ jar.archiveFileName.set("${appName}-${projectVersion}-mac.jar")
+ jar.exclude("windows/x86/**", "windows/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", "**/*.dll", "**/*.so",
+ 'META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA')
+ dependencies {
+ jar.exclude("windows/x86/**", "windows/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**",
+ 'META-INF/INDEX.LIST', 'META-INF/maven/**')
+ }
+}
+
+// Builds a JAR that only includes the files needed to run on Linux, not Windows or macOS.
+// The file size for a Linux-only JAR is about 5MB smaller than a cross-platform JAR.
+tasks.register("jarLinux") {
+ dependsOn("jar")
+ group("build")
+ jar.archiveFileName.set("${appName}-${projectVersion}-linux.jar")
+ jar.exclude("windows/x86/**", "windows/x64/**", "macos/arm64/**", "macos/x64/**", "**/*.dll", "**/*.dylib",
+ 'META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA')
+ dependencies {
+ jar.exclude("windows/x86/**", "windows/x64/**", "macos/arm64/**", "macos/x64/**",
+ 'META-INF/INDEX.LIST', 'META-INF/maven/**')
+ }
+}
+
+// Builds a JAR that only includes the files needed to run on Windows, not Linux or macOS.
+// The file size for a Windows-only JAR is about 6MB smaller than a cross-platform JAR.
+tasks.register("jarWin") {
+ dependsOn("jar")
+ group("build")
+ jar.archiveFileName.set("${appName}-${projectVersion}-win.jar")
+ jar.exclude("macos/arm64/**", "macos/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", "**/*.dylib", "**/*.so",
+ 'META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA')
+ dependencies {
+ jar.exclude("macos/arm64/**", "macos/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**",
+ 'META-INF/INDEX.LIST', 'META-INF/maven/**')
+ }
+}
+
+construo {
+ // name of the executable
+ name.set(appName)
+ // human-readable name, used for example in the `.app` name for macOS
+ humanName.set(appName)
+
+ targets.configure {
+ register("linuxX64", Target.Linux) {
+ architecture.set(Target.Architecture.X86_64)
+ jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.15%2B6/OpenJDK17U-jdk_x64_linux_hotspot_17.0.15_6.tar.gz")
+ // Linux does not currently have a way to set the icon on the executable
+ }
+ register("macM1", Target.MacOs) {
+ architecture.set(Target.Architecture.AARCH64)
+ jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.15%2B6/OpenJDK17U-jdk_aarch64_mac_hotspot_17.0.15_6.tar.gz")
+ // macOS needs an identifier
+ identifier.set("coffee.liz.abstractionengine." + appName)
+ // Optional: icon for macOS, as an ICNS file
+ macIcon.set(project.file("icons/logo.icns"))
+ }
+ register("macX64", Target.MacOs) {
+ architecture.set(Target.Architecture.X86_64)
+ jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.15%2B6/OpenJDK17U-jdk_x64_mac_hotspot_17.0.15_6.tar.gz")
+ // macOS needs an identifier
+ identifier.set("coffee.liz.abstractionengine." + appName)
+ // Optional: icon for macOS, as an ICNS file
+ macIcon.set(project.file("icons/logo.icns"))
+ }
+ register("winX64", Target.Windows) {
+ architecture.set(Target.Architecture.X86_64)
+ // Optional: icon for Windows, as a PNG
+ icon.set(project.file("icons/logo.png"))
+ jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.15%2B6/OpenJDK17U-jdk_x64_windows_hotspot_17.0.15_6.zip")
+ // Uncomment the next line to show a console when the game runs, to print messages.
+ //useConsole.set(true)
+ }
+ }
+}
+
+// Equivalent to the jar task; here for compatibility with gdx-setup.
+tasks.register('dist') {
+ dependsOn 'jar'
+}
+
+distributions {
+ main {
+ contents {
+ into('libs') {
+ project.configurations.runtimeClasspath.files.findAll { file ->
+ file.getName() != project.tasks.jar.outputs.files.singleFile.name
+ }.each { file ->
+ exclude file.name
+ }
+ }
+ }
+ }
+}
+
+startScripts.dependsOn(':lwjgl3:jar')
+startScripts.classpath = project.tasks.jar.outputs.files
+
+if(enableGraalNative == 'true') {
+ apply from: file("nativeimage.gradle")
+}
diff --git a/lwjgl3/icons/logo.icns b/lwjgl3/icons/logo.icns
new file mode 100644
index 0000000..5e41ad7
--- /dev/null
+++ b/lwjgl3/icons/logo.icns
Binary files differ
diff --git a/lwjgl3/icons/logo.ico b/lwjgl3/icons/logo.ico
new file mode 100644
index 0000000..c4f2d5e
--- /dev/null
+++ b/lwjgl3/icons/logo.ico
Binary files differ
diff --git a/lwjgl3/icons/logo.png b/lwjgl3/icons/logo.png
new file mode 100644
index 0000000..f810616
--- /dev/null
+++ b/lwjgl3/icons/logo.png
Binary files differ
diff --git a/lwjgl3/nativeimage.gradle b/lwjgl3/nativeimage.gradle
new file mode 100644
index 0000000..bee3fd2
--- /dev/null
+++ b/lwjgl3/nativeimage.gradle
@@ -0,0 +1,54 @@
+
+project(":lwjgl3") {
+ apply plugin: "org.graalvm.buildtools.native"
+
+ graalvmNative {
+ binaries {
+ main {
+ imageName = appName
+ mainClass = application.mainClass
+ requiredVersion = '23.0'
+ buildArgs.add("-march=compatibility")
+ jvmArgs.addAll("-Dfile.encoding=UTF8")
+ sharedLibrary = false
+ resources.autodetect()
+ }
+ }
+ }
+
+ run {
+ doNotTrackState("Running the app should not be affected by Graal.")
+ }
+
+ // Modified from https://lyze.dev/2021/04/29/libGDX-Internal-Assets-List/ ; thanks again, Lyze!
+ // This creates a resource-config.json file based on the contents of the assets folder (and the libGDX icons).
+ // This file is used by Graal Native to embed those specific files.
+ // This has to run before nativeCompile, so it runs at the start of an unrelated resource-handling command.
+ generateResourcesConfigFile.doFirst {
+ def assetsFolder = new File("${project.rootDir}/assets/")
+ def lwjgl3 = project(':lwjgl3')
+ def resFolder = new File("${lwjgl3.projectDir}/src/main/resources/META-INF/native-image/${lwjgl3.ext.appName}")
+ resFolder.mkdirs()
+ def resFile = new File(resFolder, "resource-config.json")
+ resFile.delete()
+ resFile.append(
+ """{
+ "resources":{
+ "includes":[
+ {
+ "pattern": ".*(""")
+ // This adds every filename in the assets/ folder to a pattern that adds those files as resources.
+ fileTree(assetsFolder).each {
+ // The backslash-Q and backslash-E escape the start and end of a literal string, respectively.
+ resFile.append("\\\\Q${it.name}\\\\E|")
+ }
+ // We also match all of the window icon images this way and the font files that are part of libGDX.
+ resFile.append(
+ """libgdx.+\\\\.png|lsans.+)"
+ }
+ ]},
+ "bundles":[]
+}"""
+ )
+ }
+}
diff --git a/lwjgl3/src/main/java/coffee/liz/abstractionengine/lwjgl3/Lwjgl3Launcher.java b/lwjgl3/src/main/java/coffee/liz/abstractionengine/lwjgl3/Lwjgl3Launcher.java
new file mode 100644
index 0000000..1c67c62
--- /dev/null
+++ b/lwjgl3/src/main/java/coffee/liz/abstractionengine/lwjgl3/Lwjgl3Launcher.java
@@ -0,0 +1,63 @@
+package coffee.liz.abstractionengine.lwjgl3;
+
+import coffee.liz.abstractionengine.app.AbstractionEngineGame;
+
+import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application;
+import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration;
+import com.badlogic.gdx.graphics.glutils.HdpiMode;
+
+/** Launches the desktop (LWJGL3) application. */
+public class Lwjgl3Launcher {
+ public static void main(final String[] args) {
+ if (StartupHelper.startNewJvmIfRequired())
+ return; // This handles macOS support and helps on Windows.
+ createApplication();
+ }
+
+ private static Lwjgl3Application createApplication() {
+ return new Lwjgl3Application(new AbstractionEngineGame(), getDefaultConfiguration());
+ }
+
+ private static Lwjgl3ApplicationConfiguration getDefaultConfiguration() {
+ final Lwjgl3ApplicationConfiguration configuration = new Lwjgl3ApplicationConfiguration();
+ configuration.setTitle("The Abstraction Engine");
+ //// 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);
+ //// Limits FPS to the refresh rate of the currently active monitor, plus 1 to
+ //// try to match fractional
+ //// refresh rates. The Vsync setting above should limit the actual FPS to match
+ //// the monitor.
+ configuration.setForegroundFPS(Lwjgl3ApplicationConfiguration.getDisplayMode().refreshRate + 1);
+ //// If you remove the above line and set Vsync to false, you can get unlimited
+ //// FPS, which can be
+ //// useful for testing performance, but can also be very stressful to some
+ //// hardware.
+ //// You may also need to configure GPU drivers to fully disable Vsync; this can
+ //// cause screen tearing.
+
+ configuration.setHdpiMode(HdpiMode.Logical);
+
+ configuration.setWindowedMode(1024, 1024);
+ configuration.setDecorated(true);
+ //// You can change these files; they are in lwjgl3/src/main/resources/ .
+ //// They can also be loaded from the root of assets/ .
+ configuration.setWindowIcon("libgdx128.png", "libgdx64.png", "libgdx32.png", "libgdx16.png");
+
+ //// This should improve compatibility with Windows machines with buggy OpenGL
+ //// drivers, Macs
+ //// with Apple Silicon that have to emulate compatibility with OpenGL anyway,
+ //// and more.
+ //// This uses the dependency `com.badlogicgames.gdx:gdx-lwjgl3-angle` to
+ //// function.
+ //// You can choose to remove the following line and the mentioned dependency if
+ //// you want; they
+ //// are not intended for games that use GL30 (which is compatibility with
+ //// OpenGL ES 3.0).
+ configuration.setOpenGLEmulation(Lwjgl3ApplicationConfiguration.GLEmulation.ANGLE_GLES20, 0, 0);
+
+ return configuration;
+ }
+}
diff --git a/lwjgl3/src/main/java/coffee/liz/abstractionengine/lwjgl3/StartupHelper.java b/lwjgl3/src/main/java/coffee/liz/abstractionengine/lwjgl3/StartupHelper.java
new file mode 100644
index 0000000..21520f9
--- /dev/null
+++ b/lwjgl3/src/main/java/coffee/liz/abstractionengine/lwjgl3/StartupHelper.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2020 damios
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at:
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+//Note, the above license and copyright applies to this file only.
+
+package coffee.liz.abstractionengine.lwjgl3;
+
+import com.badlogic.gdx.Version;
+import com.badlogic.gdx.backends.lwjgl3.Lwjgl3NativesLoader;
+import org.lwjgl.system.macosx.LibC;
+import org.lwjgl.system.macosx.ObjCRuntime;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStreamReader;
+import java.lang.management.ManagementFactory;
+import java.util.ArrayList;
+
+import static org.lwjgl.system.JNI.invokePPP;
+import static org.lwjgl.system.JNI.invokePPZ;
+import static org.lwjgl.system.macosx.ObjCRuntime.objc_getClass;
+import static org.lwjgl.system.macosx.ObjCRuntime.sel_getUid;
+
+/**
+ * Adds some utilities to ensure that the JVM was started with the
+ * {@code -XstartOnFirstThread} argument, which is required on macOS for LWJGL 3
+ * to function. Also helps on Windows when users have names with characters from
+ * outside the Latin alphabet, a common cause of startup crashes. <br>
+ * <a href=
+ * "https://jvm-gaming.org/t/starting-jvm-on-mac-with-xstartonfirstthread-programmatically/57547">Based
+ * on this java-gaming.org post by kappa</a>
+ *
+ * @author damios
+ */
+public class StartupHelper {
+
+ private static final String JVM_RESTARTED_ARG = "jvmIsRestarted";
+
+ private StartupHelper() {
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * Starts a new JVM if the application was started on macOS without the
+ * {@code -XstartOnFirstThread} argument. This also includes some code for
+ * Windows, for the case where the user's home directory includes certain
+ * non-Latin-alphabet characters (without this code, most LWJGL3 apps fail
+ * immediately for those users). Returns whether a new JVM was started and thus
+ * no code should be executed.
+ * <p>
+ * <u>Usage:</u>
+ *
+ * <pre>
+ * <code>
+ * public static void main(String... args) {
+ * if (StartupHelper.startNewJvmIfRequired(true)) return; // This handles macOS support and helps on Windows.
+ * // after this is the actual main method code
+ * }
+ * </code>
+ * </pre>
+ *
+ * @param redirectOutput
+ * whether the output of the new JVM should be rerouted to the old
+ * JVM, so it can be accessed in the same place; keeps the old JVM
+ * running if enabled
+ * @return whether a new JVM was started and thus no code should be executed in
+ * this one
+ */
+ public static boolean startNewJvmIfRequired(boolean redirectOutput) {
+ String osName = System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT);
+ if (!osName.contains("mac")) {
+ if (osName.contains("windows")) {
+ // Here, we are trying to work around an issue with how LWJGL3 loads its
+ // extracted .dll files.
+ // By default, LWJGL3 extracts to the directory specified by "java.io.tmpdir",
+ // which is usually the user's home.
+ // If the user's name has non-ASCII (or some non-alphanumeric) characters in it,
+ // that would fail.
+ // By extracting to the relevant "ProgramData" folder, which is usually
+ // "C:\ProgramData", we avoid this.
+ // We also temporarily change the "user.name" property to one without any chars
+ // that would be invalid.
+ // We revert our changes immediately after loading LWJGL3 natives.
+ String programData = System.getenv("ProgramData");
+ if (programData == null)
+ programData = "C:\\Temp\\"; // if ProgramData isn't set, try some fallback.
+ String prevTmpDir = System.getProperty("java.io.tmpdir", programData);
+ String prevUser = System.getProperty("user.name", "libGDX_User");
+ System.setProperty("java.io.tmpdir", programData + "/libGDX-temp");
+ System.setProperty("user.name",
+ ("User_" + prevUser.hashCode() + "_GDX" + Version.VERSION).replace('.', '_'));
+ Lwjgl3NativesLoader.load();
+ System.setProperty("java.io.tmpdir", prevTmpDir);
+ System.setProperty("user.name", prevUser);
+ }
+ return false;
+ }
+
+ // There is no need for -XstartOnFirstThread on Graal native image
+ if (!System.getProperty("org.graalvm.nativeimage.imagecode", "").isEmpty()) {
+ return false;
+ }
+
+ // Checks if we are already on the main thread, such as from running via
+ // Construo.
+ long objc_msgSend = ObjCRuntime.getLibrary().getFunctionAddress("objc_msgSend");
+ long NSThread = objc_getClass("NSThread");
+ long currentThread = invokePPP(NSThread, sel_getUid("currentThread"), objc_msgSend);
+ boolean isMainThread = invokePPZ(currentThread, sel_getUid("isMainThread"), objc_msgSend);
+ if (isMainThread)
+ return false;
+
+ long pid = LibC.getpid();
+
+ // check whether -XstartOnFirstThread is enabled
+ if ("1".equals(System.getenv("JAVA_STARTED_ON_FIRST_THREAD_" + pid))) {
+ return false;
+ }
+
+ // check whether the JVM was previously restarted
+ // avoids looping, but most certainly leads to a crash
+ if ("true".equals(System.getProperty(JVM_RESTARTED_ARG))) {
+ System.err.println(
+ "There was a problem evaluating whether the JVM was started with the -XstartOnFirstThread argument.");
+ return false;
+ }
+
+ // Restart the JVM with -XstartOnFirstThread
+ ArrayList<String> jvmArgs = new ArrayList<>();
+ String separator = System.getProperty("file.separator", "/");
+ // The following line is used assuming you target Java 8, the minimum for
+ // LWJGL3.
+ String javaExecPath = System.getProperty("java.home") + separator + "bin" + separator + "java";
+ // If targeting Java 9 or higher, you could use the following instead of the
+ // above line:
+ // String javaExecPath = ProcessHandle.current().info().command().orElseThrow();
+
+ if (!(new File(javaExecPath)).exists()) {
+ System.err.println(
+ "A Java installation could not be found. If you are distributing this app with a bundled JRE, be sure to set the -XstartOnFirstThread argument manually!");
+ return false;
+ }
+
+ jvmArgs.add(javaExecPath);
+ jvmArgs.add("-XstartOnFirstThread");
+ jvmArgs.add("-D" + JVM_RESTARTED_ARG + "=true");
+ jvmArgs.addAll(ManagementFactory.getRuntimeMXBean().getInputArguments());
+ jvmArgs.add("-cp");
+ jvmArgs.add(System.getProperty("java.class.path"));
+ String mainClass = System.getenv("JAVA_MAIN_CLASS_" + pid);
+ if (mainClass == null) {
+ StackTraceElement[] trace = Thread.currentThread().getStackTrace();
+ if (trace.length > 0) {
+ mainClass = trace[trace.length - 1].getClassName();
+ } else {
+ System.err.println("The main class could not be determined.");
+ return false;
+ }
+ }
+ jvmArgs.add(mainClass);
+
+ try {
+ if (!redirectOutput) {
+ ProcessBuilder processBuilder = new ProcessBuilder(jvmArgs);
+ processBuilder.start();
+ } else {
+ Process process = (new ProcessBuilder(jvmArgs)).redirectErrorStream(true).start();
+ BufferedReader processOutput = new BufferedReader(new InputStreamReader(process.getInputStream()));
+ String line;
+
+ while ((line = processOutput.readLine()) != null) {
+ System.out.println(line);
+ }
+
+ process.waitFor();
+ }
+ } catch (Exception e) {
+ System.err.println("There was a problem restarting the JVM");
+ e.printStackTrace();
+ }
+
+ return true;
+ }
+
+ /**
+ * Starts a new JVM if the application was started on macOS without the
+ * {@code -XstartOnFirstThread} argument. Returns whether a new JVM was started
+ * and thus no code should be executed. Redirects the output of the new JVM to
+ * the old one.
+ * <p>
+ * <u>Usage:</u>
+ *
+ * <pre>
+ * public static void main(String... args) {
+ * if (StartupHelper.startNewJvmIfRequired())
+ * return; // This handles macOS support and helps on Windows.
+ * // the actual main method code
+ * }
+ * </pre>
+ *
+ * @return whether a new JVM was started and thus no code should be executed in
+ * this one
+ */
+ public static boolean startNewJvmIfRequired() {
+ return startNewJvmIfRequired(true);
+ }
+} \ No newline at end of file
diff --git a/lwjgl3/src/main/resources/libgdx128.png b/lwjgl3/src/main/resources/libgdx128.png
new file mode 100644
index 0000000..f810616
--- /dev/null
+++ b/lwjgl3/src/main/resources/libgdx128.png
Binary files differ
diff --git a/lwjgl3/src/main/resources/libgdx16.png b/lwjgl3/src/main/resources/libgdx16.png
new file mode 100644
index 0000000..a6b1327
--- /dev/null
+++ b/lwjgl3/src/main/resources/libgdx16.png
Binary files differ
diff --git a/lwjgl3/src/main/resources/libgdx32.png b/lwjgl3/src/main/resources/libgdx32.png
new file mode 100644
index 0000000..9447b39
--- /dev/null
+++ b/lwjgl3/src/main/resources/libgdx32.png
Binary files differ
diff --git a/lwjgl3/src/main/resources/libgdx64.png b/lwjgl3/src/main/resources/libgdx64.png
new file mode 100644
index 0000000..7513f3b
--- /dev/null
+++ b/lwjgl3/src/main/resources/libgdx64.png
Binary files differ
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..11d1983
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,8 @@
+plugins {
+ // Applies the foojay-resolver plugin to allow automatic download of JDKs.
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+// A list of which subprojects to load as part of the same larger project.
+// You can remove Strings from the list and reload the Gradle project
+// if you want to temporarily disable a subproject.
+include 'lwjgl3', 'core'