aboutsummaryrefslogtreecommitdiff
path: root/core/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/main')
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java2
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/actor/LifeGridActor.java7
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java51
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/mainmenu/MainMenuAudio.java206
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/utils/FunctionUtils.java19
5 files changed, 262 insertions, 23 deletions
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
index d2cf996..b54776f 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
@@ -43,7 +43,7 @@ public class AbstractionEngineGame extends Game {
}
private BitmapFont initFont(final int size, final String path) {
- final int scaleForHidpi = 2;
+ final int scaleForHidpi = 2;
final FreeTypeFontGenerator gen = new FreeTypeFontGenerator(Gdx.files.internal(path));
final FreeTypeFontGenerator.FreeTypeFontParameter params = new FreeTypeFontGenerator.FreeTypeFontParameter();
params.characters = RENDERABLE_CHARS;
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/actor/LifeGridActor.java b/core/src/main/java/coffee/liz/abstractionengine/app/actor/LifeGridActor.java
index e90b522..6692413 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/actor/LifeGridActor.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/actor/LifeGridActor.java
@@ -34,6 +34,8 @@ public class LifeGridActor extends Actor {
private final Color cellColor = new Color();
@Setter
private Viewport viewport;
+ @Setter
+ private Vec2<Float> parallaxOffset = Vec2f.ZERO;
@Override
public void act(final float delta) {
@@ -83,15 +85,14 @@ public class LifeGridActor extends Actor {
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 Vec2<Float> drawPos = gridPos.floatValue().scale(cellSize).plus(parallaxOffset);
final float alivePercentage = state.getAlivePercentage();
if (alivePercentage <= 0.0f) {
return;
}
shapeRenderer.setColor(interpolateColor(alivePercentage));
- shapeRenderer.rect(worldPos.getX(), worldPos.getY(), cellSize.getX(), cellSize.getY());
+ shapeRenderer.rect(drawPos.getX(), drawPos.getY(), cellSize.getX(), cellSize.getY());
});
}
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
index 9279b45..f1b6b02 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java
@@ -4,6 +4,7 @@ import coffee.liz.abstractionengine.app.AbstractionEngineGame;
import coffee.liz.abstractionengine.app.Theme;
import coffee.liz.abstractionengine.app.actor.BlockyButton;
import coffee.liz.abstractionengine.app.actor.LifeGridActor;
+import coffee.liz.abstractionengine.app.screen.mainmenu.MainMenuAudio;
import coffee.liz.abstractionengine.app.life.CellState;
import coffee.liz.abstractionengine.app.life.LifeInput;
import coffee.liz.abstractionengine.app.life.LifeSystem;
@@ -11,10 +12,12 @@ 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.math.Vector3;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.utils.ScreenUtils;
import lombok.RequiredArgsConstructor;
@@ -30,6 +33,7 @@ public class MainMenu implements Screen {
private static final float BUTTON_WIDTH = 50f;
private static final float BUTTON_HEIGHT = 12f;
private static final float BUTTON_SPACING = 4f;
+ private static final float PARALLAX_STRENGTH = 3f;
private final World<LifeInput> world = new DAGWorld<>(Map.of(LifeSystem.class, new LifeSystem(GRID_DIMENSIONS)));
private final AbstractionEngineGame game;
@@ -38,6 +42,7 @@ public class MainMenu implements Screen {
private Stage stage;
private LifeGridActor lifeGridActor;
+ private MainMenuAudio audioSystem;
@Override
public void show() {
@@ -51,6 +56,9 @@ public class MainMenu implements Screen {
createButtons();
+ audioSystem = new MainMenuAudio(GRID_DIMENSIONS);
+ audioSystem.start();
+
Gdx.input.setInputProcessor(stage);
}
@@ -62,12 +70,7 @@ public class MainMenu implements Screen {
final BlockyButton playButton = createButton("Play", centerX, startY);
playButton.setOnClick(() -> game.setScreen(new GameScreen(game)));
-
- final BlockyButton quitButton = createButton("Quit", centerX, startY - (BUTTON_HEIGHT + BUTTON_SPACING) * 2);
- quitButton.setOnClick(Gdx.app::exit);
-
stage.addActor(playButton);
- stage.addActor(quitButton);
}
private BlockyButton createButton(final String text, final float x, final float y) {
@@ -79,30 +82,40 @@ public class MainMenu implements Screen {
}
private static Set<Vec2<Integer>> createGliderPattern() {
- final int[][] glider = {
- {0, 1, 0},
- {0, 1, 1},
- {1, 0, 1},
- };
+ final int[][] glider = {{0, 1, 0}, {0, 1, 1}, {1, 0, 1},};
final int offsetX = 5;
final int offsetY = 40;
- final Set<Vec2<Integer>> gliderPattern = new HashSet<>();
- for (int y = 0; y < 3; y++)
- for (int x = 0; x < 3; x++)
- if (glider[y][x] == 1)
- gliderPattern.add(Vec2i.builder().y(offsetY + y).x(offsetX + x).build());
+ final Set<Vec2<Integer>> gliderPattern = new HashSet<>();
+ for (int y = 0; y < 3; y++)
+ for (int x = 0; x < 3; x++)
+ if (glider[y][x] == 1)
+ gliderPattern.add(Vec2i.builder().y(offsetY + y).x(offsetX + x).build());
- return gliderPattern;
+ return gliderPattern;
}
@Override
public void render(final float delta) {
game.viewport.apply();
+
+ audioSystem.update(world.query(Set.of(GridPosition.class, CellState.class)));
+
ScreenUtils.clear(Theme.BG);
+ updateParallax();
stage.act(delta);
stage.draw();
}
+ private void updateParallax() {
+ final Vector3 cursor = game.viewport.unproject(new Vector3(Gdx.input.getX(), Gdx.input.getY(), 0));
+ final Vec2<Float> worldSize = Vec2f.builder().x(game.viewport.getWorldWidth()).y(game.viewport.getWorldHeight())
+ .build();
+ final Vec2<Float> normalized = Vec2f.builder().x(cursor.x).y(cursor.y).build()
+ .scale(worldSize.transform(x -> 1f / x, y -> 1f / y)).transform(x -> x - 0.5f, y -> y - 0.5f);
+ final Vec2<Float> parallax = normalized.scale(-PARALLAX_STRENGTH, -PARALLAX_STRENGTH);
+ lifeGridActor.setParallaxOffset(parallax);
+ }
+
@Override
public void resize(final int width, final int height) {
game.viewport.update(width, height, true);
@@ -118,13 +131,13 @@ public class MainMenu implements Screen {
@Override
public void hide() {
+ dispose();
}
@Override
public void dispose() {
+ audioSystem.dispose();
world.query(Set.of()).forEach(world::removeEntity);
- if (stage != null) {
- stage.dispose();
- }
+ stage.dispose();
}
}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/mainmenu/MainMenuAudio.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/mainmenu/MainMenuAudio.java
new file mode 100644
index 0000000..516fded
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/mainmenu/MainMenuAudio.java
@@ -0,0 +1,206 @@
+package coffee.liz.abstractionengine.app.screen.mainmenu;
+
+import coffee.liz.abstractionengine.app.life.CellState;
+import coffee.liz.abstractionengine.app.utils.FunctionUtils;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Entity;
+import com.badlogic.gdx.Gdx;
+import com.badlogic.gdx.audio.AudioDevice;
+import com.badlogic.gdx.utils.Disposable;
+import lombok.Getter;
+
+import java.time.Duration;
+import java.util.Collection;
+
+/**
+ * Uses grid activity and centroid position to shape chord density and
+ * positional tones.
+ */
+public class MainMenuAudio implements Runnable, Disposable {
+ private static final int SAMPLE_RATE = 44100;
+ private static final int BUFFER_SIZE = 1024;
+ private static final float TWO_PI = (float) (2.0 * Math.PI);
+ private static final float ONE = 1.0f;
+
+ private static final float BASE_FREQ = 130.81f;
+ private static final float[] CHORD_RATIOS = {1.0f, 1.25f, 1.5f, 2.0f, 2.5f, 3.0f, 4.0f, 5.0f, 6.0f};
+ private static final float[] CHORD_VOLUMES = {0.25f, 0.2f, 0.22f, 0.18f, 0.15f, 0.14f, 0.12f, 0.1f, 0.08f};
+ private static final int CHORD_THRESHOLD_PADDING = 3;
+ private static final float CHORD_GAIN_SCALAR = 2.5f;
+ private static final float DETUNE_DEPTH = 0.003f;
+ private static final float SHIMMER_DEPTH = 0.01f;
+ private static final int SHIMMER_START_HARMONIC = 3;
+
+ private static final float[] SCALE_RATIOS = {1.0f, 9f / 8f, 5f / 4f, 4f / 3f, 3f / 2f, 5f / 3f, 15f / 8f, 2.0f};
+ private static final float POSITION_BASE_FREQ = 261.63f;
+ private static final float POSITION_Y_FREQ_MULTIPLIER = 1.5f;
+
+ private static final float LFO_FREQ = 0.05f;
+ private static final float SHIMMER_FREQ = 0.3f;
+ private static final float SMOOTHING = 0.997f;
+ private static final float POSITION_SMOOTHING = 0.99f;
+ private static final float MASTER_VOLUME = 0.25f;
+ private static final float MIN_ACTIVITY_LEVEL = 0.15f;
+ private static final float DEFAULT_ACTIVITY_LEVEL = 0.15f;
+ private static final float MAX_ACTIVITY_FRACTION = 0.3f;
+ private static final float MIN_CENTROID_ACTIVITY = 0.01f;
+ private static final float MIN_POSITION_INTENSITY = 0.01f;
+ private static final float POSITION_VOLUME_SCALE = 0.3f;
+ private static final float POSITION_Y_GAIN = 0.7f;
+ private static final float LFO_HALF_RANGE = 0.5f;
+ private static final float MIDPOINT = 0.5f;
+ private static final int AUDIO_THREAD_JOIN_TIMEOUT_MS = 100;
+
+ private final AudioDevice device;
+ private final Vec2<Integer> gridDimensions;
+ private final float maxActivity;
+ private final float[] buffer = new float[BUFFER_SIZE];
+ private final float[] chordPhases = new float[CHORD_RATIOS.length];
+
+ private float positionXPhase = 0.0f;
+ private float positionYPhase = 0.0f;
+ private float lfoPhase = 0.0f;
+ private float shimmerPhase = 0.0f;
+
+ private float smoothedActivity = DEFAULT_ACTIVITY_LEVEL;
+ private float smoothedX = MIDPOINT;
+ private float smoothedY = MIDPOINT;
+ private float smoothedPositionIntensity = 0.0f;
+
+ private volatile float activity = DEFAULT_ACTIVITY_LEVEL;
+ private volatile float centroidX = MIDPOINT;
+ private volatile float centroidY = MIDPOINT;
+ private volatile float centroidWeight = 0.0f;
+ private volatile boolean running = true;
+
+ @Getter
+ private Thread audioThread;
+
+ public MainMenuAudio(final Vec2<Integer> gridDimensions) {
+ this.device = Gdx.audio.newAudioDevice(SAMPLE_RATE, true);
+ this.gridDimensions = gridDimensions;
+ this.maxActivity = gridDimensions.getX() * gridDimensions.getY() * MAX_ACTIVITY_FRACTION;
+ }
+
+ public void start() {
+ audioThread = new Thread(this, "main-menu-audio");
+ audioThread.setDaemon(true);
+ audioThread.start();
+ }
+
+ /**
+ * Updates activity metrics derived from current grid entities. This drives
+ * chord density and positional tones in the audio thread.
+ */
+ public void update(final Collection<Entity> entities) {
+ float totalActivity = 0.0f;
+ float weightedX = 0.0f;
+ float weightedY = 0.0f;
+
+ for (final Entity entity : entities) {
+ if (!entity.has(GridPosition.class) || !entity.has(CellState.class)) {
+ continue;
+ }
+ final Vec2<Integer> pos = entity.get(GridPosition.class).getPosition();
+ final float alive = entity.get(CellState.class).getAlivePercentage();
+
+ totalActivity += alive;
+ weightedX += pos.getX() * alive;
+ weightedY += pos.getY() * alive;
+ }
+
+ activity = Math.min(ONE, totalActivity / maxActivity);
+
+ if (totalActivity > MIN_CENTROID_ACTIVITY) {
+ centroidX = (weightedX / totalActivity) / gridDimensions.getX();
+ centroidY = (weightedY / totalActivity) / gridDimensions.getY();
+ centroidWeight = Math.min(ONE, totalActivity / maxActivity);
+ } else {
+ centroidWeight = 0.0f;
+ }
+ }
+
+ @Override
+ public void run() {
+ while (running) {
+ smoothedActivity = smoothedActivity * SMOOTHING + activity * (1.0f - SMOOTHING);
+ smoothedX = smoothedX * POSITION_SMOOTHING + centroidX * (1.0f - POSITION_SMOOTHING);
+ smoothedY = smoothedY * POSITION_SMOOTHING + centroidY * (1.0f - POSITION_SMOOTHING);
+ smoothedPositionIntensity = smoothedPositionIntensity * SMOOTHING + centroidWeight * (1.0f - SMOOTHING);
+
+ final float level = Math.max(MIN_ACTIVITY_LEVEL, smoothedActivity);
+
+ for (int i = 0; i < BUFFER_SIZE; i++) {
+ buffer[i] = sampleChord(level) + samplePosition();
+ buffer[i] *= MASTER_VOLUME;
+ }
+ device.writeSamples(buffer, 0, BUFFER_SIZE);
+ }
+ }
+
+ private float sampleChord(final float level) {
+ final float lfo = (float) Math.sin(lfoPhase * TWO_PI) * LFO_HALF_RANGE + MIDPOINT;
+ lfoPhase += LFO_FREQ / SAMPLE_RATE;
+ if (lfoPhase > ONE) {
+ lfoPhase -= ONE;
+ }
+
+ final float shimmer = (float) Math.sin(shimmerPhase * TWO_PI) * LFO_HALF_RANGE + MIDPOINT;
+ shimmerPhase += SHIMMER_FREQ / SAMPLE_RATE;
+ if (shimmerPhase > ONE) {
+ shimmerPhase -= ONE;
+ }
+
+ float sample = 0.0f;
+ for (int t = 0; t < CHORD_RATIOS.length; t++) {
+ final float toneThreshold = t / (float) (CHORD_RATIOS.length + CHORD_THRESHOLD_PADDING);
+ final float toneGain = Math.min(ONE, Math.max(0, (level - toneThreshold) * CHORD_GAIN_SCALAR));
+
+ final float detune = ONE + (lfo - MIDPOINT) * DETUNE_DEPTH;
+ final float shimmerMod = t > SHIMMER_START_HARMONIC ? (ONE + (shimmer - MIDPOINT) * SHIMMER_DEPTH) : ONE;
+
+ final float freq = BASE_FREQ * CHORD_RATIOS[t] * detune * shimmerMod;
+ chordPhases[t] += freq / SAMPLE_RATE;
+ if (chordPhases[t] > ONE) {
+ chordPhases[t] -= ONE;
+ }
+
+ sample += (float) Math.sin(chordPhases[t] * TWO_PI) * CHORD_VOLUMES[t] * toneGain;
+ }
+
+ return sample;
+ }
+
+ private float samplePosition() {
+ if (smoothedPositionIntensity < MIN_POSITION_INTENSITY) {
+ return 0.0f;
+ }
+
+ final int xIndex = Math.min((int) (smoothedX * SCALE_RATIOS.length), SCALE_RATIOS.length - 1);
+ final int yIndex = Math.min((int) (smoothedY * SCALE_RATIOS.length), SCALE_RATIOS.length - 1);
+
+ final float xFreq = POSITION_BASE_FREQ * SCALE_RATIOS[xIndex];
+ final float yFreq = POSITION_BASE_FREQ * POSITION_Y_FREQ_MULTIPLIER * SCALE_RATIOS[yIndex];
+
+ positionXPhase += xFreq / SAMPLE_RATE;
+ if (positionXPhase > ONE) {
+ positionXPhase -= ONE;
+ }
+ positionYPhase += yFreq / SAMPLE_RATE;
+ if (positionYPhase > ONE) {
+ positionYPhase -= ONE;
+ }
+
+ final float positionVolume = smoothedPositionIntensity * POSITION_VOLUME_SCALE;
+ return (float) Math.sin(positionXPhase * TWO_PI) * positionVolume
+ + (float) Math.sin(positionYPhase * TWO_PI) * positionVolume * POSITION_Y_GAIN;
+ }
+
+ @Override
+ public void dispose() {
+ running = false;
+ device.dispose();
+ FunctionUtils.runUninterrupted(() -> getAudioThread().join(Duration.ofMillis(AUDIO_THREAD_JOIN_TIMEOUT_MS)));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/utils/FunctionUtils.java b/core/src/main/java/coffee/liz/abstractionengine/app/utils/FunctionUtils.java
new file mode 100644
index 0000000..32f2dc1
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/utils/FunctionUtils.java
@@ -0,0 +1,19 @@
+package coffee.liz.abstractionengine.app.utils;
+
+public final class FunctionUtils {
+ private FunctionUtils() {
+ }
+
+ public static <E extends Throwable> void runUninterrupted(final ThrowableRunnable<E> run) {
+ try {
+ run.run();
+ } catch (final Throwable e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @FunctionalInterface
+ public interface ThrowableRunnable<E extends Throwable> {
+ void run() throws E;
+ }
+}