{
+
+ public SettingsAsset(File file) {
+ super(file, Settings.class);
+ }
+
+ public int getVolume() {
+ return getContent().volume;
+ }
+
+ public int getFxVolume() {
+ return getContent().fxVolume;
+ }
+
+ public int getMusicVolume() {
+ return getContent().musicVolume;
+ }
+
+ public Locale getLocale() {
+ return Locale.forLanguageTag(getContent().locale);
+ }
+
+ public boolean getFullscreen() {
+ return getContent().fullScreen;
+ }
+
+ public String getTheme() {
+ return getContent().theme;
+ }
+
+ public String getLayoutSize() {
+ return getContent().layoutSize;
+ }
+
+ public void setVolume(int volume) {
+ getContent().volume = volume;
+ save();
+ }
+
+ public void setFxVolume(int fxVolume) {
+ getContent().fxVolume = fxVolume;
+ save();
+ }
+
+ public void setMusicVolume(int musicVolume) {
+ getContent().musicVolume = musicVolume;
+ save();
+ }
+
+ public void setLocale(String locale) {
+ getContent().locale = locale;
+ save();
+ }
+
+ public void setFullscreen(boolean fullscreen) {
+ getContent().fullScreen = fullscreen;
+ save();
+ }
+
+ public void setTheme(String theme) {
+ getContent().theme = theme;
+ save();
+ }
+
+ public void setLayoutSize(String layoutSize) {
+ getContent().layoutSize = layoutSize;
+ save();
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/SoundEffectAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/SoundEffectAsset.java
new file mode 100644
index 0000000..b85951b
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/SoundEffectAsset.java
@@ -0,0 +1,80 @@
+package org.toop.framework.asset.resources;
+
+import java.io.*;
+import java.nio.file.Files;
+import javax.sound.sampled.*;
+import org.toop.framework.asset.types.FileExtension;
+import org.toop.framework.asset.types.LoadableResource;
+
+@FileExtension({"wav"})
+public class SoundEffectAsset extends BaseResource implements LoadableResource {
+ private byte[] rawData;
+
+ public SoundEffectAsset(final File audioFile) {
+ super(audioFile);
+ }
+
+ // Gets a new clip to play
+ public Clip getNewClip()
+ throws LineUnavailableException, UnsupportedAudioFileException, IOException {
+ // Get a new clip from audio system
+ Clip clip = AudioSystem.getClip();
+
+ // Insert a new audio stream into the clip
+ AudioInputStream inputStream = this.getAudioStream();
+ AudioFormat baseFormat = inputStream.getFormat();
+ if (baseFormat.getSampleSizeInBits() > 16)
+ inputStream = downSampleAudio(inputStream, baseFormat);
+ clip.open(
+ inputStream); // ^ Clip can only run 16 bit and lower, thus downsampling necessary.
+ return clip;
+ }
+
+ // Generates a new audio stream from byte array
+ private AudioInputStream getAudioStream() throws UnsupportedAudioFileException, IOException {
+ // Check if raw data is loaded into memory
+ if (!this.isLoaded()) {
+ this.load();
+ }
+
+ // Turn rawData into an input stream and turn that into an audio input stream;
+ return AudioSystem.getAudioInputStream(new ByteArrayInputStream(this.rawData));
+ }
+
+ private AudioInputStream downSampleAudio(
+ AudioInputStream audioInputStream, AudioFormat baseFormat) {
+ AudioFormat decodedFormat =
+ new AudioFormat(
+ AudioFormat.Encoding.PCM_SIGNED,
+ baseFormat.getSampleRate(),
+ 16, // force 16-bit
+ baseFormat.getChannels(),
+ baseFormat.getChannels() * 2,
+ baseFormat.getSampleRate(),
+ false // little-endian
+ );
+
+ return AudioSystem.getAudioInputStream(decodedFormat, audioInputStream);
+ }
+
+ @Override
+ public void load() {
+ try {
+ this.rawData = Files.readAllBytes(file.toPath());
+ this.isLoaded = true;
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public void unload() {
+ this.rawData = null;
+ this.isLoaded = false;
+ }
+
+ @Override
+ public boolean isLoaded() {
+ return this.isLoaded;
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/TextAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/TextAsset.java
new file mode 100644
index 0000000..88c9c33
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/TextAsset.java
@@ -0,0 +1,43 @@
+package org.toop.framework.asset.resources;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import org.toop.framework.asset.types.FileExtension;
+import org.toop.framework.asset.types.LoadableResource;
+
+@FileExtension({"txt", "json", "xml"})
+public class TextAsset extends BaseResource implements LoadableResource {
+ private String content;
+
+ public TextAsset(File file) {
+ super(file);
+ }
+
+ @Override
+ public void load() {
+ try {
+ byte[] bytes = Files.readAllBytes(getFile().toPath());
+ this.content = new String(bytes, StandardCharsets.UTF_8);
+ this.isLoaded = true;
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to load text asset: " + getFile(), e);
+ }
+ }
+
+ @Override
+ public void unload() {
+ this.content = null;
+ this.isLoaded = false;
+ }
+
+ @Override
+ public boolean isLoaded() {
+ return this.isLoaded;
+ }
+
+ public String getContent() {
+ return this.content;
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/types/BundledResource.java b/framework/src/main/java/org/toop/framework/asset/types/BundledResource.java
new file mode 100644
index 0000000..a243188
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/types/BundledResource.java
@@ -0,0 +1,77 @@
+package org.toop.framework.asset.types;
+
+import java.io.File;
+import org.toop.framework.asset.ResourceLoader;
+
+/**
+ * Represents a resource that can be composed of multiple files, or "bundled" together under a
+ * common base name.
+ *
+ * Implementing classes allow an {@link ResourceLoader} to automatically merge multiple related
+ * files into a single resource instance.
+ *
+ *
Typical use cases include:
+ *
+ *
+ * - Localization assets, where multiple `.properties` files (e.g., `messages_en.properties`,
+ * `messages_nl.properties`) are grouped under the same logical resource.
+ *
- Sprite sheets, tile sets, or other multi-file resources that logically belong together.
+ *
+ *
+ * Implementing classes must provide:
+ *
+ *
+ * - {@link #loadFile(File)}: Logic to load or merge an individual file into the resource.
+ *
- {@link #getBaseName()}: A consistent base name used to group multiple files into this
+ * resource.
+ *
+ *
+ * Example usage:
+ *
+ *
{@code
+ * public class LocalizationAsset extends BaseResource implements BundledResource {
+ * private final String baseName;
+ *
+ * public LocalizationAsset(File file) {
+ * super(file);
+ * this.baseName = extractBaseName(file.getName());
+ * loadFile(file);
+ * }
+ *
+ * @Override
+ * public void loadFile(File file) {
+ * // merge file into existing bundles
+ * }
+ *
+ * @Override
+ * public String getBaseName() {
+ * return baseName;
+ * }
+ * }
+ * }
+ *
+ * When used with an asset loader, all files sharing the same base name are automatically merged
+ * into a single resource instance.
+ */
+public interface BundledResource {
+
+ /**
+ * Load or merge an additional file into this resource.
+ *
+ * @param file the file to load or merge
+ */
+ void loadFile(File file);
+
+ /**
+ * Return a base name for grouping multiple files into this single resource. Files with the same
+ * base name are automatically merged by the loader.
+ *
+ * @return the base name used to identify this bundled resource
+ */
+ String getBaseName();
+
+ // /**
+ // Returns the name
+ // */
+ // String getDefaultName();
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/types/FileExtension.java b/framework/src/main/java/org/toop/framework/asset/types/FileExtension.java
new file mode 100644
index 0000000..ab70275
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/types/FileExtension.java
@@ -0,0 +1,44 @@
+package org.toop.framework.asset.types;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.toop.framework.asset.ResourceLoader;
+import org.toop.framework.asset.resources.BaseResource;
+
+/**
+ * Annotation to declare which file extensions a {@link BaseResource} subclass can handle.
+ *
+ *
This annotation is processed by the {@link ResourceLoader} to automatically register resource
+ * types for specific file extensions. Each extension listed will be mapped to the annotated
+ * resource class, allowing the loader to instantiate the correct type when scanning files.
+ *
+ *
Usage example:
+ *
+ *
{@code
+ * @FileExtension({"png", "jpg"})
+ * public class ImageAsset extends BaseResource implements LoadableResource {
+ * ...
+ * }
+ * }
+ *
+ * Key points:
+ *
+ *
+ * - The annotation is retained at runtime for reflection-based registration.
+ *
- Can only be applied to types (classes) that extend {@link BaseResource}.
+ *
- Multiple extensions can be specified in the {@code value()} array.
+ *
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface FileExtension {
+ /**
+ * The list of file extensions (without leading dot) that the annotated resource class can
+ * handle.
+ *
+ * @return array of file extensions
+ */
+ String[] value();
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/types/LoadableResource.java b/framework/src/main/java/org/toop/framework/asset/types/LoadableResource.java
new file mode 100644
index 0000000..d43f9c0
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/types/LoadableResource.java
@@ -0,0 +1,69 @@
+package org.toop.framework.asset.types;
+
+import org.toop.framework.asset.ResourceLoader;
+
+/**
+ * Represents a resource that can be explicitly loaded and unloaded.
+ *
+ * Any class implementing {@code LoadableResource} is responsible for managing its own loading
+ * and unloading logic, such as reading files, initializing data structures, or allocating external
+ * resources.
+ *
+ *
Implementing classes must define the following behaviors:
+ *
+ *
+ * - {@link #load()}: Load the resource into memory or perform necessary initialization.
+ *
- {@link #unload()}: Release any held resources or memory when the resource is no longer
+ * needed.
+ *
- {@link #isLoaded()}: Return {@code true} if the resource has been successfully loaded and
+ * is ready for use, {@code false} otherwise.
+ *
+ *
+ * Typical usage:
+ *
+ *
{@code
+ * public class MyFontAsset extends BaseResource implements LoadableResource {
+ * private boolean loaded = false;
+ *
+ * @Override
+ * public void load() {
+ * // Load font file into memory
+ * loaded = true;
+ * }
+ *
+ * @Override
+ * public void unload() {
+ * // Release resources if needed
+ * loaded = false;
+ * }
+ *
+ * @Override
+ * public boolean isLoaded() {
+ * return loaded;
+ * }
+ * }
+ * }
+ *
+ * This interface is commonly used with {@link PreloadResource} to allow automatic loading by an
+ * {@link ResourceLoader} if desired.
+ */
+public interface LoadableResource {
+ /**
+ * Load the resource into memory or initialize it. This method may throw runtime exceptions if
+ * loading fails.
+ */
+ void load();
+
+ /**
+ * Unload the resource and free any associated resources. After this call, {@link #isLoaded()}
+ * should return false.
+ */
+ void unload();
+
+ /**
+ * Check whether the resource has been successfully loaded.
+ *
+ * @return true if the resource is loaded and ready for use, false otherwise
+ */
+ boolean isLoaded();
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/types/PreloadResource.java b/framework/src/main/java/org/toop/framework/asset/types/PreloadResource.java
new file mode 100644
index 0000000..bf4fafd
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/types/PreloadResource.java
@@ -0,0 +1,41 @@
+package org.toop.framework.asset.types;
+
+import org.toop.framework.asset.ResourceLoader;
+
+/**
+ * Marker interface for resources that should be **automatically loaded** by the {@link
+ * ResourceLoader}.
+ *
+ *
Extends {@link LoadableResource}, so any implementing class must provide the standard {@link
+ * LoadableResource#load()} and {@link LoadableResource#unload()} methods, as well as the {@link
+ * LoadableResource#isLoaded()} check.
+ *
+ *
When a resource implements {@code PreloadResource}, the {@code ResourceLoader} will invoke
+ * {@link LoadableResource#load()} automatically after the resource is discovered and instantiated,
+ * without requiring manual loading by the user.
+ *
+ *
Typical usage:
+ *
+ *
{@code
+ * public class MyFontAsset extends BaseResource implements PreloadResource {
+ * @Override
+ * public void load() {
+ * // load the font into memory
+ * }
+ *
+ * @Override
+ * public void unload() {
+ * // release resources if needed
+ * }
+ *
+ * @Override
+ * public boolean isLoaded() {
+ * return loaded;
+ * }
+ * }
+ * }
+ *
+ * Note: Only use this interface for resources that are safe to load at startup, as it may
+ * increase memory usage or startup time.
+ */
+public interface PreloadResource extends LoadableResource {}
diff --git a/framework/src/main/java/org/toop/framework/audio/AudioVolumeManager.java b/framework/src/main/java/org/toop/framework/audio/AudioVolumeManager.java
new file mode 100644
index 0000000..add826b
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/audio/AudioVolumeManager.java
@@ -0,0 +1,100 @@
+package org.toop.framework.audio;
+
+import javafx.scene.media.MediaPlayer;
+import javax.sound.sampled.Clip;
+import javax.sound.sampled.FloatControl;
+import org.toop.framework.audio.events.AudioEvents;
+import org.toop.framework.eventbus.EventFlow;
+
+public class AudioVolumeManager {
+ private final SoundManager sM;
+
+ private double volume = 1.0;
+ private double fxVolume = 1.0;
+ private double musicVolume = 1.0;
+
+ public AudioVolumeManager(SoundManager soundManager) {
+ this.sM = soundManager;
+
+ new EventFlow()
+ .listen(this::handleVolumeChange)
+ .listen(this::handleFxVolumeChange)
+ .listen(this::handleMusicVolumeChange)
+ .listen(this::handleGetCurrentVolume)
+ .listen(this::handleGetCurrentFxVolume)
+ .listen(this::handleGetCurrentMusicVolume);
+ }
+
+ public void updateMusicVolume(MediaPlayer mediaPlayer) {
+ mediaPlayer.setVolume(this.musicVolume * this.volume);
+ }
+
+ public void updateSoundEffectVolume(Clip clip) {
+ if (clip.isControlSupported(FloatControl.Type.MASTER_GAIN)) {
+ FloatControl volumeControl =
+ (FloatControl) clip.getControl(FloatControl.Type.MASTER_GAIN);
+ float min = volumeControl.getMinimum();
+ float max = volumeControl.getMaximum();
+ float dB =
+ (float)
+ (Math.log10(Math.max(this.fxVolume * this.volume, 0.0001))
+ * 20.0); // convert linear to dB
+ dB = Math.max(min, Math.min(max, dB));
+ volumeControl.setValue(dB);
+ }
+ }
+
+ private double limitVolume(double volume) {
+ if (volume > 1.0) return 1.0;
+ else return Math.max(volume, 0.0);
+ }
+
+ private void handleFxVolumeChange(AudioEvents.ChangeFxVolume event) {
+ this.fxVolume = limitVolume(event.newVolume() / 100);
+ for (Clip clip : sM.getActiveSoundEffects().values()) {
+ updateSoundEffectVolume(clip);
+ }
+ }
+
+ private void handleVolumeChange(AudioEvents.ChangeVolume event) {
+ this.volume = limitVolume(event.newVolume() / 100);
+ for (MediaPlayer mediaPlayer : sM.getActiveMusic()) {
+ this.updateMusicVolume(mediaPlayer);
+ }
+ for (Clip clip : sM.getActiveSoundEffects().values()) {
+ updateSoundEffectVolume(clip);
+ }
+ }
+
+ private void handleMusicVolumeChange(AudioEvents.ChangeMusicVolume event) {
+ this.musicVolume = limitVolume(event.newVolume() / 100);
+ System.out.println(this.musicVolume);
+ System.out.println(this.volume);
+ for (MediaPlayer mediaPlayer : sM.getActiveMusic()) {
+ this.updateMusicVolume(mediaPlayer);
+ }
+ }
+
+ private void handleGetCurrentVolume(AudioEvents.GetCurrentVolume event) {
+ new EventFlow()
+ .addPostEvent(
+ new AudioEvents.GetCurrentVolumeResponse(volume * 100, event.snowflakeId()))
+ .asyncPostEvent();
+ }
+
+ private void handleGetCurrentFxVolume(AudioEvents.GetCurrentFxVolume event) {
+ new EventFlow()
+ .addPostEvent(
+ new AudioEvents.GetCurrentFxVolumeResponse(
+ fxVolume * 100, event.snowflakeId()))
+ .asyncPostEvent();
+ }
+
+ private void handleGetCurrentMusicVolume(AudioEvents.GetCurrentMusicVolume event) {
+ new EventFlow()
+ .addPostEvent(
+ new AudioEvents.GetCurrentMusicVolumeResponse(
+ musicVolume * 100, event.snowflakeId()))
+ .asyncPostEvent();
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/audio/SoundManager.java b/framework/src/main/java/org/toop/framework/audio/SoundManager.java
new file mode 100644
index 0000000..f6608ba
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/audio/SoundManager.java
@@ -0,0 +1,197 @@
+package org.toop.framework.audio;
+
+import java.io.*;
+import java.util.*;
+import javafx.scene.media.MediaPlayer;
+import javax.sound.sampled.*;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.toop.framework.SnowflakeGenerator;
+import org.toop.framework.asset.ResourceManager;
+import org.toop.framework.asset.ResourceMeta;
+import org.toop.framework.asset.resources.MusicAsset;
+import org.toop.framework.asset.resources.SoundEffectAsset;
+import org.toop.framework.audio.events.AudioEvents;
+import org.toop.framework.eventbus.EventFlow;
+
+public class SoundManager {
+ private static final Logger logger = LogManager.getLogger(SoundManager.class);
+ private final List activeMusic = new ArrayList<>();
+ private final Queue backgroundMusicQueue = new LinkedList<>();
+ private final Map activeSoundEffects = new HashMap<>();
+ private final HashMap audioResources = new HashMap<>();
+ private final SnowflakeGenerator idGenerator =
+ new SnowflakeGenerator(); // TODO: Don't create a new generator
+ private final AudioVolumeManager audioVolumeManager = new AudioVolumeManager(this);
+
+ public SoundManager() {
+ // Get all Audio Resources and add them to a list.
+ for (ResourceMeta asset :
+ ResourceManager.getAllOfType(SoundEffectAsset.class)) {
+ try {
+ this.addAudioResource(asset);
+ } catch (IOException | LineUnavailableException | UnsupportedAudioFileException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ new EventFlow()
+ .listen(this::handlePlaySound)
+ .listen(this::handleStopSound)
+ .listen(this::handleMusicStart)
+ .listen(
+ AudioEvents.ClickButton.class,
+ _ -> {
+ try {
+ playSound("medium-button-click.wav", false);
+ } catch (UnsupportedAudioFileException
+ | LineUnavailableException
+ | IOException e) {
+ logger.error(e);
+ }
+ });
+ }
+
+ private void handlePlaySound(AudioEvents.PlayEffect event) {
+ try {
+ this.playSound(event.fileName(), event.loop());
+ } catch (UnsupportedAudioFileException | LineUnavailableException | IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void handleStopSound(AudioEvents.StopEffect event) {
+ this.stopSound(event.clipId());
+ }
+
+ private void addAudioResource(ResourceMeta audioAsset)
+ throws IOException, UnsupportedAudioFileException, LineUnavailableException {
+
+ this.audioResources.put(audioAsset.getName(), audioAsset.getResource());
+ }
+
+ private void handleMusicStart(AudioEvents.StartBackgroundMusic e) {
+ backgroundMusicQueue.clear();
+ List shuffledArray =
+ new ArrayList<>(
+ ResourceManager.getAllOfType(MusicAsset.class).stream()
+ .map(ResourceMeta::getResource)
+ .toList());
+ Collections.shuffle(shuffledArray);
+ backgroundMusicQueue.addAll(shuffledArray);
+ backgroundMusicPlayer();
+ }
+
+ private void addBackgroundMusic(MusicAsset musicAsset) {
+ backgroundMusicQueue.add(musicAsset);
+ }
+
+ private void backgroundMusicPlayer() {
+ MusicAsset ma = backgroundMusicQueue.poll();
+ if (ma == null) return;
+
+ MediaPlayer mediaPlayer = new MediaPlayer(ma.getMedia());
+
+ mediaPlayer.setOnEndOfMedia(
+ () -> {
+ addBackgroundMusic(ma);
+ activeMusic.remove(mediaPlayer);
+ mediaPlayer.dispose();
+ ma.unload();
+ backgroundMusicPlayer(); // play next
+ });
+
+ mediaPlayer.setOnStopped(
+ () -> {
+ addBackgroundMusic(ma);
+ activeMusic.remove(mediaPlayer);
+ ma.unload();
+ });
+
+ mediaPlayer.setOnError(
+ () -> {
+ addBackgroundMusic(ma);
+ activeMusic.remove(mediaPlayer);
+ ma.unload();
+ });
+
+ audioVolumeManager.updateMusicVolume(mediaPlayer);
+ mediaPlayer.play();
+ activeMusic.add(mediaPlayer);
+ logger.info("Playing background music: {}", ma.getFile().getName());
+ logger.info(
+ "Background music next in line: {}",
+ backgroundMusicQueue.peek().getFile().getName());
+ }
+
+ private long playSound(String audioFileName, boolean loop)
+ throws UnsupportedAudioFileException, LineUnavailableException, IOException {
+ SoundEffectAsset asset = audioResources.get(audioFileName);
+
+ // Return -1 which indicates resource wasn't available
+ if (asset == null) {
+ logger.warn("Unable to load audio asset: {}", audioFileName);
+ return -1;
+ }
+
+ // Get a new clip from resource
+ Clip clip = asset.getNewClip();
+
+ // Set volume of clip
+ audioVolumeManager.updateSoundEffectVolume(clip);
+
+ // If supposed to loop make it loop, else just start it once
+ if (loop) {
+ clip.loop(Clip.LOOP_CONTINUOUSLY);
+ } else {
+ clip.start();
+ }
+
+ logger.debug("Playing sound: {}", asset.getFile().getName());
+
+ // Generate id for clip
+ long clipId = idGenerator.nextId();
+
+ // store it so we can stop it later
+ activeSoundEffects.put(clipId, clip); // TODO: Do on snowflake for specific sound to stop
+
+ // remove when finished (only for non-looping sounds)
+ clip.addLineListener(
+ event -> {
+ if (event.getType() == LineEvent.Type.STOP && !clip.isRunning()) {
+ activeSoundEffects.remove(clipId);
+ clip.close();
+ }
+ });
+
+ // Return id so it can be stopped
+ return clipId;
+ }
+
+ public void stopSound(long clipId) {
+ Clip clip = activeSoundEffects.get(clipId);
+
+ if (clip == null) {
+ return;
+ }
+
+ clip.stop();
+ clip.close();
+ activeSoundEffects.remove(clipId);
+ }
+
+ public void stopAllSounds() {
+ for (Clip clip : activeSoundEffects.values()) {
+ clip.stop();
+ clip.close();
+ }
+ activeSoundEffects.clear();
+ }
+
+ public Map getActiveSoundEffects() {
+ return this.activeSoundEffects;
+ }
+
+ public List getActiveMusic() {
+ return activeMusic;
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/audio/events/AudioEvents.java b/framework/src/main/java/org/toop/framework/audio/events/AudioEvents.java
new file mode 100644
index 0000000..aed23ee
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/audio/events/AudioEvents.java
@@ -0,0 +1,98 @@
+package org.toop.framework.audio.events;
+
+import java.util.Map;
+import org.toop.framework.eventbus.events.EventWithSnowflake;
+import org.toop.framework.eventbus.events.EventWithoutSnowflake;
+import org.toop.framework.eventbus.events.EventsBase;
+
+public class AudioEvents extends EventsBase {
+ /** Starts playing a sound. */
+ public record PlayEffect(String fileName, boolean loop) implements EventWithoutSnowflake {}
+
+ public record StopEffect(long clipId) implements EventWithoutSnowflake {}
+
+ public record StartBackgroundMusic() implements EventWithoutSnowflake {}
+
+ public record ChangeVolume(double newVolume) implements EventWithoutSnowflake {}
+
+ public record ChangeFxVolume(double newVolume) implements EventWithoutSnowflake {}
+
+ public record ChangeMusicVolume(double newVolume) implements EventWithoutSnowflake {}
+
+ public record GetCurrentVolume(long snowflakeId) implements EventWithSnowflake {
+ @Override
+ public Map result() {
+ return Map.of();
+ }
+
+ @Override
+ public long eventSnowflake() {
+ return snowflakeId;
+ }
+ }
+
+ public record GetCurrentVolumeResponse(double currentVolume, long snowflakeId)
+ implements EventWithSnowflake {
+ @Override
+ public Map result() {
+ return Map.of();
+ }
+
+ @Override
+ public long eventSnowflake() {
+ return snowflakeId;
+ }
+ }
+
+ public record GetCurrentFxVolume(long snowflakeId) implements EventWithSnowflake {
+ @Override
+ public Map result() {
+ return Map.of();
+ }
+
+ @Override
+ public long eventSnowflake() {
+ return this.snowflakeId;
+ }
+ }
+
+ public record GetCurrentMusicVolume(long snowflakeId) implements EventWithSnowflake {
+ @Override
+ public Map result() {
+ return Map.of();
+ }
+
+ @Override
+ public long eventSnowflake() {
+ return this.snowflakeId;
+ }
+ }
+
+ public record GetCurrentFxVolumeResponse(double currentVolume, long snowflakeId)
+ implements EventWithSnowflake {
+ @Override
+ public Map result() {
+ return Map.of();
+ }
+
+ @Override
+ public long eventSnowflake() {
+ return this.snowflakeId;
+ }
+ }
+
+ public record GetCurrentMusicVolumeResponse(double currentVolume, long snowflakeId)
+ implements EventWithSnowflake {
+ @Override
+ public Map result() {
+ return Map.of();
+ }
+
+ @Override
+ public long eventSnowflake() {
+ return this.snowflakeId;
+ }
+ }
+
+ public record ClickButton() implements EventWithoutSnowflake {}
+}
diff --git a/framework/src/main/java/org/toop/framework/networking/events/NetworkEvents.java b/framework/src/main/java/org/toop/framework/networking/events/NetworkEvents.java
index df59a69..49583af 100644
--- a/framework/src/main/java/org/toop/framework/networking/events/NetworkEvents.java
+++ b/framework/src/main/java/org/toop/framework/networking/events/NetworkEvents.java
@@ -41,7 +41,7 @@ public class NetworkEvents extends EventsBase {
/** Response indicating a challenge was received. */
public record ChallengeResponse(
- long clientId, String challengerName, String gameType, String challengeId)
+ long clientId, String challengerName, String challengeId, String gameType)
implements EventWithoutSnowflake {}
/** Response containing a list of players for a client. */
@@ -62,7 +62,7 @@ public class NetworkEvents extends EventsBase {
implements EventWithoutSnowflake {}
/** Response indicating a game move occurred. */
- public record GameMoveResponse(long clientId, String player, String details, String move)
+ public record GameMoveResponse(long clientId, String player, String move, String details)
implements EventWithoutSnowflake {}
/** Response indicating it is the player's turn. */
diff --git a/framework/src/main/java/org/toop/framework/settings/Settings.java b/framework/src/main/java/org/toop/framework/settings/Settings.java
new file mode 100644
index 0000000..052107c
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/settings/Settings.java
@@ -0,0 +1,11 @@
+package org.toop.framework.settings;
+
+public class Settings {
+ public boolean fullScreen = false;
+ public String locale = "en";
+ public String theme = "dark";
+ public String layoutSize = "medium";
+ public int volume = 100;
+ public int fxVolume = 20;
+ public int musicVolume = 15;
+}
diff --git a/framework/src/test/java/org/toop/framework/networking/events/NetworkEventsTest.java b/framework/src/test/java/org/toop/framework/networking/events/NetworkEventsTest.java
index 3641549..f2a129d 100644
--- a/framework/src/test/java/org/toop/framework/networking/events/NetworkEventsTest.java
+++ b/framework/src/test/java/org/toop/framework/networking/events/NetworkEventsTest.java
@@ -35,10 +35,10 @@ class NetworkEventsTest {
@Test
void testChallengeResponse() {
NetworkEvents.ChallengeResponse event =
- new NetworkEvents.ChallengeResponse(1L, "Alice", "Chess", "ch001");
- assertEquals("Alice", event.challengerName());
- assertEquals("Chess", event.gameType());
- assertEquals("ch001", event.challengeId());
+ new NetworkEvents.ChallengeResponse(1L, "John", "1", "tic-tac-toe");
+ assertEquals("John", event.challengerName());
+ assertEquals("1", event.challengeId());
+ assertEquals("tic-tac-toe", event.gameType());
}
@Test
diff --git a/game/src/main/java/org/toop/game/Game.java b/game/src/main/java/org/toop/game/Game.java
index b37bd73..9b4259c 100644
--- a/game/src/main/java/org/toop/game/Game.java
+++ b/game/src/main/java/org/toop/game/Game.java
@@ -3,55 +3,37 @@ package org.toop.game;
import java.util.Arrays;
public abstract class Game {
- public enum State {
- NORMAL, LOSE, DRAW, WIN,
- }
+ public enum State {
+ NORMAL,
+ DRAW,
+ WIN,
+ }
- public record Move(int position, char value) {}
+ public record Move(int position, char value) {}
- public static final char EMPTY = (char)0;
+ public static final char EMPTY = (char) 0;
- protected final int rowSize;
- protected final int columnSize;
- protected final char[] board;
+ public final int rowSize;
+ public final int columnSize;
+ public final char[] board;
- protected final Player[] players;
- protected int currentPlayer;
+ protected Game(int rowSize, int columnSize) {
+ assert rowSize > 0 && columnSize > 0;
- protected Game(int rowSize, int columnSize, Player... players) {
- assert rowSize > 0 && columnSize > 0;
- assert players.length >= 1;
+ this.rowSize = rowSize;
+ this.columnSize = columnSize;
- this.rowSize = rowSize;
- this.columnSize = columnSize;
+ board = new char[rowSize * columnSize];
+ Arrays.fill(board, EMPTY);
+ }
- board = new char[rowSize * columnSize];
- Arrays.fill(board, EMPTY);
+ protected Game(Game other) {
+ rowSize = other.rowSize;
+ columnSize = other.columnSize;
+ board = Arrays.copyOf(other.board, other.board.length);
+ }
- this.players = players;
- currentPlayer = 0;
- }
+ public abstract Move[] getLegalMoves();
- protected Game(Game other) {
- rowSize = other.rowSize;
- columnSize = other.columnSize;
- board = Arrays.copyOf(other.board, other.board.length);
-
- players = Arrays.copyOf(other.players, other.players.length);
- currentPlayer = other.currentPlayer;
- }
-
- public int getRowSize() { return rowSize; }
- public int getColumnSize() { return columnSize; }
- public char[] getBoard() { return board; }
-
- public Player[] getPlayers() { return players; }
- public Player getCurrentPlayer() { return players[currentPlayer]; }
-
- protected void nextPlayer() {
- currentPlayer = (currentPlayer + 1) % players.length;
- }
-
- public abstract Move[] getLegalMoves();
- public abstract State play(Move move);
-}
\ No newline at end of file
+ public abstract State play(Move move);
+}
diff --git a/game/src/main/java/org/toop/game/Player.java b/game/src/main/java/org/toop/game/Player.java
deleted file mode 100644
index 2dc4a2f..0000000
--- a/game/src/main/java/org/toop/game/Player.java
+++ /dev/null
@@ -1,3 +0,0 @@
-package org.toop.game;
-
-public record Player(String name, char... values) {}
\ No newline at end of file
diff --git a/game/src/main/java/org/toop/game/TurnBasedGame.java b/game/src/main/java/org/toop/game/TurnBasedGame.java
new file mode 100644
index 0000000..b4eb1d3
--- /dev/null
+++ b/game/src/main/java/org/toop/game/TurnBasedGame.java
@@ -0,0 +1,27 @@
+package org.toop.game;
+
+public abstract class TurnBasedGame extends Game {
+ public final int turns;
+
+ protected int currentTurn;
+
+ protected TurnBasedGame(int rowSize, int columnSize, int turns) {
+ super(rowSize, columnSize);
+ assert turns >= 2;
+ this.turns = turns;
+ }
+
+ protected TurnBasedGame(TurnBasedGame other) {
+ super(other);
+ turns = other.turns;
+ currentTurn = other.currentTurn;
+ }
+
+ protected void nextTurn() {
+ currentTurn = (currentTurn + 1) % turns;
+ }
+
+ public int getCurrentTurn() {
+ return currentTurn;
+ }
+}
diff --git a/game/src/main/java/org/toop/game/othello/Othello.java b/game/src/main/java/org/toop/game/othello/Othello.java
new file mode 100644
index 0000000..435527a
--- /dev/null
+++ b/game/src/main/java/org/toop/game/othello/Othello.java
@@ -0,0 +1,19 @@
+package org.toop.game.othello;
+
+import org.toop.game.TurnBasedGame;
+
+public final class Othello extends TurnBasedGame {
+ Othello() {
+ super(8, 8, 2);
+ }
+
+ @Override
+ public Move[] getLegalMoves() {
+ return new Move[0];
+ }
+
+ @Override
+ public State play(Move move) {
+ return null;
+ }
+}
diff --git a/game/src/main/java/org/toop/game/othello/OthelloAI.java b/game/src/main/java/org/toop/game/othello/OthelloAI.java
new file mode 100644
index 0000000..40f147c
--- /dev/null
+++ b/game/src/main/java/org/toop/game/othello/OthelloAI.java
@@ -0,0 +1,11 @@
+package org.toop.game.othello;
+
+import org.toop.game.AI;
+import org.toop.game.Game;
+
+public final class OthelloAI extends AI {
+ @Override
+ public Game.Move findBestMove(Othello game, int depth) {
+ return null;
+ }
+}
diff --git a/game/src/main/java/org/toop/game/tictactoe/TicTacToe.java b/game/src/main/java/org/toop/game/tictactoe/TicTacToe.java
index 4b39df2..0fa6ca8 100644
--- a/game/src/main/java/org/toop/game/tictactoe/TicTacToe.java
+++ b/game/src/main/java/org/toop/game/tictactoe/TicTacToe.java
@@ -1,84 +1,100 @@
package org.toop.game.tictactoe;
-import org.toop.game.Game;
-import org.toop.game.Player;
-
import java.util.ArrayList;
+import org.toop.game.TurnBasedGame;
-public final class TicTacToe extends Game {
- private int movesLeft;
+public final class TicTacToe extends TurnBasedGame {
+ private int movesLeft;
- public TicTacToe(String player1, String player2) {
- super(3, 3, new Player(player1, 'X'), new Player(player2, 'O'));
- movesLeft = board.length;
- }
+ public TicTacToe() {
+ super(3, 3, 2);
+ movesLeft = board.length;
+ }
- public TicTacToe(TicTacToe other) {
- super(other);
- movesLeft = other.movesLeft;
- }
+ public TicTacToe(TicTacToe other) {
+ super(other);
+ movesLeft = other.movesLeft;
+ }
- @Override
- public Move[] getLegalMoves() {
- final ArrayList legalMoves = new ArrayList<>();
+ @Override
+ public Move[] getLegalMoves() {
+ final ArrayList legalMoves = new ArrayList<>();
+ final char currentValue = getCurrentValue();
- for (int i = 0; i < board.length; i++) {
- if (board[i] == EMPTY) {
- legalMoves.add(new Move(i, getCurrentPlayer().values()[0]));
- }
- }
+ for (int i = 0; i < board.length; i++) {
+ if (board[i] == EMPTY) {
+ legalMoves.add(new Move(i, currentValue));
+ }
+ }
- return legalMoves.toArray(new Move[0]);
- }
+ return legalMoves.toArray(new Move[0]);
+ }
- @Override
- public State play(Move move) {
- assert move != null;
- assert move.position() >= 0 && move.position() < board.length;
- assert move.value() == getCurrentPlayer().values()[0];
+ @Override
+ public State play(Move move) {
+ assert move != null;
+ assert move.position() >= 0 && move.position() < board.length;
+ assert move.value() == getCurrentValue();
- board[move.position()] = move.value();
- movesLeft--;
+ board[move.position()] = move.value();
+ movesLeft--;
- if (checkForWin()) {
- return State.WIN;
- }
+ if (checkForWin()) {
+ return State.WIN;
+ }
- if (movesLeft <= 0) {
- return State.DRAW;
- }
+ nextTurn();
- nextPlayer();
- return State.NORMAL;
- }
+ if (movesLeft <= 2) {
+ if (movesLeft <= 0 || checkForEarlyDraw(this)) {
+ return State.DRAW;
+ }
+ }
- private boolean checkForWin() {
- // Horizontal
- for (int i = 0; i < 3; i++) {
- final int index = i * 3;
+ return State.NORMAL;
+ }
- if (board[index] != EMPTY
- && board[index] == board[index + 1]
- && board[index] == board[index + 2]) {
- return true;
- }
- }
+ private boolean checkForWin() {
+ // Horizontal
+ for (int i = 0; i < 3; i++) {
+ final int index = i * 3;
- // Vertical
- for (int i = 0; i < 3; i++) {
- if (board[i] != EMPTY
- && board[i] == board[i + 3]
- && board[i] == board[i + 6]) {
- return true;
- }
- }
+ if (board[index] != EMPTY
+ && board[index] == board[index + 1]
+ && board[index] == board[index + 2]) {
+ return true;
+ }
+ }
- // B-Slash
- if (board[0] != EMPTY && board[0] == board[4] && board[0] == board[8]) {
- return true;
- }
+ // Vertical
+ for (int i = 0; i < 3; i++) {
+ if (board[i] != EMPTY && board[i] == board[i + 3] && board[i] == board[i + 6]) {
+ return true;
+ }
+ }
- // F-Slash
- return board[2] != EMPTY && board[2] == board[4] && board[2] == board[6];
- }
-}
\ No newline at end of file
+ // B-Slash
+ if (board[0] != EMPTY && board[0] == board[4] && board[0] == board[8]) {
+ return true;
+ }
+
+ // F-Slash
+ return board[2] != EMPTY && board[2] == board[4] && board[2] == board[6];
+ }
+
+ private boolean checkForEarlyDraw(TicTacToe game) {
+ for (final Move move : game.getLegalMoves()) {
+ final TicTacToe copy = new TicTacToe(game);
+
+ if (copy.play(move) == State.WIN || !checkForEarlyDraw(copy)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private char getCurrentValue() {
+ return currentTurn == 0 ? 'X' : 'O';
+ }
+}
diff --git a/game/src/test/java/org/toop/game/PlayerTest.java b/game/src/test/java/org/toop/game/PlayerTest.java
deleted file mode 100644
index 3a3f14b..0000000
--- a/game/src/test/java/org/toop/game/PlayerTest.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package org.toop.game;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-class PlayerTest {
- private Player playerA;
- private Player playerB;
- private Player playerC;
-
- @BeforeEach
- void setup() {
- playerA = new Player("test A", 'x', 'Z', 'i');
- playerB = new Player("test B", 'O', (char)12, (char)-34, 's');
- playerC = new Player("test C", (char)9, '9', (char)-9, '0', 'X', 'O');
- }
-
- @Test
- void testNameGetter_returnsTrueForValidName() {
- assertEquals("test A", playerA.name());
- assertEquals("test B", playerB.name());
- assertEquals("test C", playerC.name());
- }
-
- @Test
- void testValuesGetter_returnsTrueForValidValues() {
- final char[] valuesA = playerA.values();
- assertEquals('x', valuesA[0]);
- assertEquals('Z', valuesA[1]);
- assertEquals('i', valuesA[2]);
-
- final char[] valuesB = playerB.values();
- assertEquals('O', valuesB[0]);
- assertEquals(12, valuesB[1]);
- assertEquals((char)-34, valuesB[2]);
- assertEquals('s', valuesB[3]);
-
- final char[] valuesC = playerC.values();
- assertEquals((char)9, valuesC[0]);
- assertEquals('9', valuesC[1]);
- assertEquals((char)-9, valuesC[2]);
- assertEquals('0', valuesC[3]);
- assertEquals('X', valuesC[4]);
- assertEquals('O', valuesC[5]);
- }
-}
\ No newline at end of file
diff --git a/game/src/test/java/org/toop/game/tictactoe/TicTacToeAITest.java b/game/src/test/java/org/toop/game/tictactoe/TicTacToeAITest.java
index a320631..325b8ee 100644
--- a/game/src/test/java/org/toop/game/tictactoe/TicTacToeAITest.java
+++ b/game/src/test/java/org/toop/game/tictactoe/TicTacToeAITest.java
@@ -1,83 +1,81 @@
package org.toop.game.tictactoe;
-import org.toop.game.Game;
-
-import java.util.Set;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
import static org.junit.jupiter.api.Assertions.*;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.toop.game.Game;
+
class TicTacToeAITest {
- private TicTacToe game;
- private TicTacToeAI ai;
+ private TicTacToe game;
+ private TicTacToeAI ai;
- @BeforeEach
- void setup() {
- game = new TicTacToe("AI", "AI");
- ai = new TicTacToeAI();
- }
+ @BeforeEach
+ void setup() {
+ game = new TicTacToe();
+ ai = new TicTacToeAI();
+ }
- @Test
- void testBestMove_returnWinningMoveWithDepth1() {
- // X X -
- // O O -
- // - - -
- game.play(new Game.Move(0, 'X'));
- game.play(new Game.Move(3, 'O'));
- game.play(new Game.Move(1, 'X'));
- game.play(new Game.Move(4, 'O'));
+ @Test
+ void testBestMove_returnWinningMoveWithDepth1() {
+ // X X -
+ // O O -
+ // - - -
+ game.play(new Game.Move(0, 'X'));
+ game.play(new Game.Move(3, 'O'));
+ game.play(new Game.Move(1, 'X'));
+ game.play(new Game.Move(4, 'O'));
- final Game.Move move = ai.findBestMove(game, 1);
+ final Game.Move move = ai.findBestMove(game, 1);
- assertNotNull(move);
- assertEquals('X', move.value());
- assertEquals(2, move.position());
- }
+ assertNotNull(move);
+ assertEquals('X', move.value());
+ assertEquals(2, move.position());
+ }
- @Test
- void testBestMove_blockOpponentWinDepth1() {
- // - - -
- // O - -
- // X X -
- game.play(new Game.Move(6, 'X'));
- game.play(new Game.Move(3, 'O'));
- game.play(new Game.Move(7, 'X'));
+ @Test
+ void testBestMove_blockOpponentWinDepth1() {
+ // - - -
+ // O - -
+ // X X -
+ game.play(new Game.Move(6, 'X'));
+ game.play(new Game.Move(3, 'O'));
+ game.play(new Game.Move(7, 'X'));
- final Game.Move move = ai.findBestMove(game, 1);
+ final Game.Move move = ai.findBestMove(game, 1);
- assertNotNull(move);
- assertEquals('O', move.value());
- assertEquals(8, move.position());
- }
+ assertNotNull(move);
+ assertEquals('O', move.value());
+ assertEquals(8, move.position());
+ }
- @Test
- void testBestMove_preferCornerOnEmpty() {
- final Game.Move move = ai.findBestMove(game, 0);
+ @Test
+ void testBestMove_preferCornerOnEmpty() {
+ final Game.Move move = ai.findBestMove(game, 0);
- assertNotNull(move);
- assertEquals('X', move.value());
- assertTrue(Set.of(0, 2, 6, 8).contains(move.position()));
- }
+ assertNotNull(move);
+ assertEquals('X', move.value());
+ assertTrue(Set.of(0, 2, 6, 8).contains(move.position()));
+ }
- @Test
- void testBestMove_findBestMoveDraw() {
- // O X -
- // - O X
- // X O X
- game.play(new Game.Move(1, 'X'));
- game.play(new Game.Move(0, 'O'));
- game.play(new Game.Move(5, 'X'));
- game.play(new Game.Move(4, 'O'));
- game.play(new Game.Move(6, 'X'));
- game.play(new Game.Move(7, 'O'));
- game.play(new Game.Move(8, 'X'));
+ @Test
+ void testBestMove_findBestMoveDraw() {
+ // O X -
+ // - O X
+ // X O X
+ game.play(new Game.Move(1, 'X'));
+ game.play(new Game.Move(0, 'O'));
+ game.play(new Game.Move(5, 'X'));
+ game.play(new Game.Move(4, 'O'));
+ game.play(new Game.Move(6, 'X'));
+ game.play(new Game.Move(7, 'O'));
+ game.play(new Game.Move(8, 'X'));
- final Game.Move move = ai.findBestMove(game, game.getLegalMoves().length);
+ final Game.Move move = ai.findBestMove(game, game.getLegalMoves().length);
- assertNotNull(move);
- assertEquals('O', move.value());
- assertEquals(2, move.position());
- }
-}
\ No newline at end of file
+ assertNotNull(move);
+ assertEquals('O', move.value());
+ assertEquals(2, move.position());
+ }
+}
diff --git a/pom.xml b/pom.xml
index bece215..a6c34cf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -107,71 +107,19 @@