diff options
Diffstat (limited to 'core/src/main/java/coffee')
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; + } +} |
