diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index 215ca2a..028e10f 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -18,7 +18,7 @@ jobs:
fetch-depth: 0 # Fix for incremental formatting
- uses: actions/setup-java@v5
with:
- java-version: '24'
+ java-version: '25'
distribution: 'temurin'
cache: maven
- name: Run Format Check
@@ -30,12 +30,12 @@ jobs:
needs: formatting-check
strategy:
matrix:
- os: [ubuntu-latest, windows-latest, macos-latest]
+ os: [ubuntu-latest] #windows-latest, macos-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
- java-version: '24'
+ java-version: '25'
distribution: 'temurin'
cache: maven
- name: Run Unittests
diff --git a/.gitignore b/.gitignore
index 323576f..b561d9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -94,3 +94,11 @@ nb-configuration.xml
# Ignore Gradle build output directory
build
+
+##############################
+## Hanze
+##############################
+newgamesver-release-V1.jar
+server.properties
+gameserver.log.*
+gameserver.log
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index d801bf4..dcffce8 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -7,7 +7,6 @@
-
diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
index e5bfff7..46f4d3b 100644
--- a/.idea/dictionaries/project.xml
+++ b/.idea/dictionaries/project.xml
@@ -2,8 +2,11 @@
aosp
+ cliddcompileerrorprone
+ flushnl
+ gaafgamelistplayerlisttictactoe
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..c168b80
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 97dd9e8..64c32f6 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -13,7 +13,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/resourceBundles.xml b/.idea/resourceBundles.xml
new file mode 100644
index 0000000..362dcdf
--- /dev/null
+++ b/.idea/resourceBundles.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+ localization
+
+
+
\ No newline at end of file
diff --git a/app/pom.xml b/app/pom.xml
index ca89a53..17ed5af 100644
--- a/app/pom.xml
+++ b/app/pom.xml
@@ -6,6 +6,7 @@
0.1
+ org.toop.Main2525
@@ -13,6 +14,12 @@
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ 2.46.1
+
+
org.tooppism_framework
@@ -46,6 +53,41 @@
UTF-8
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ 2.46.1
+
+
+ origin/main
+
+
+
+
+
+ .gitattributes
+ .gitignore
+
+
+
+
+
+ true
+ 4
+
+
+
+
+
+
+ 1.28.0
+
+ true
+ true
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/org/toop/Main.java b/app/src/main/java/org/toop/Main.java
index 64a5288..8a801ab 100644
--- a/app/src/main/java/org/toop/Main.java
+++ b/app/src/main/java/org/toop/Main.java
@@ -1,6 +1,9 @@
package org.toop;
import org.toop.app.App;
+import org.toop.framework.asset.AssetLoader;
+import org.toop.framework.asset.AssetManager;
+import org.toop.framework.audio.SoundManager;
import org.toop.framework.networking.NetworkingClientManager;
import org.toop.framework.networking.NetworkingInitializationException;
@@ -11,6 +14,8 @@ public final class Main {
}
private static void initSystems() throws NetworkingInitializationException {
- new NetworkingClientManager();
+ AssetManager.loadAssets(new AssetLoader("app/src/main/resources/assets"));
+ new Thread(NetworkingClientManager::new).start();
+ new Thread(SoundManager::new).start();
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/toop/app/menu/CreditsMenu.java b/app/src/main/java/org/toop/app/menu/CreditsMenu.java
index 96c77cd..e2f0b5f 100644
--- a/app/src/main/java/org/toop/app/menu/CreditsMenu.java
+++ b/app/src/main/java/org/toop/app/menu/CreditsMenu.java
@@ -1,6 +1,15 @@
package org.toop.app.menu;
+import org.toop.framework.asset.AssetManager;
+import org.toop.framework.asset.resources.LocalizationAsset;
+import org.toop.local.AppContext;
+
+import java.util.Locale;
+import java.util.ResourceBundle;
+
public final class CreditsMenu extends Menu {
- public CreditsMenu() {
+ private Locale currentLocale = AppContext.getLocale();
+ private LocalizationAsset loc = AssetManager.get("localization.properties");
+ public CreditsMenu() {
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/toop/app/menu/OptionsMenu.java b/app/src/main/java/org/toop/app/menu/OptionsMenu.java
index 541bb73..ea21dcb 100644
--- a/app/src/main/java/org/toop/app/menu/OptionsMenu.java
+++ b/app/src/main/java/org/toop/app/menu/OptionsMenu.java
@@ -1,6 +1,15 @@
package org.toop.app.menu;
+import org.toop.framework.asset.AssetManager;
+import org.toop.framework.asset.resources.LocalizationAsset;
+import org.toop.local.AppContext;
+
+import java.util.Locale;
+import java.util.ResourceBundle;
+
public final class OptionsMenu extends Menu {
- public OptionsMenu() {
+ private Locale currentLocale = AppContext.getLocale();
+ private LocalizationAsset loc = AssetManager.get("localization.properties");
+ public OptionsMenu() {
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/toop/local/AppContext.java b/app/src/main/java/org/toop/local/AppContext.java
new file mode 100644
index 0000000..3b1bb47
--- /dev/null
+++ b/app/src/main/java/org/toop/local/AppContext.java
@@ -0,0 +1,14 @@
+package org.toop.local;
+
+import java.util.Locale;
+
+public class AppContext {
+ private static Locale currentLocale = Locale.getDefault();
+
+ public static void setCurrentLocale(Locale locale) {
+ currentLocale = locale;
+ }
+ public static Locale getLocale() {
+ return currentLocale;
+ }
+}
diff --git a/app/src/main/resources/assets/audio/fx/dramatic.wav b/app/src/main/resources/assets/audio/fx/dramatic.wav
new file mode 100644
index 0000000..e57fbe7
Binary files /dev/null and b/app/src/main/resources/assets/audio/fx/dramatic.wav differ
diff --git a/app/src/main/resources/assets/audio/fx/hitsound0.wav b/app/src/main/resources/assets/audio/fx/hitsound0.wav
new file mode 100644
index 0000000..da99370
Binary files /dev/null and b/app/src/main/resources/assets/audio/fx/hitsound0.wav differ
diff --git a/app/src/main/resources/assets/audio/fx/hitsound1.wav b/app/src/main/resources/assets/audio/fx/hitsound1.wav
new file mode 100644
index 0000000..0f2ab26
Binary files /dev/null and b/app/src/main/resources/assets/audio/fx/hitsound1.wav differ
diff --git a/app/src/main/resources/assets/audio/fx/mainmenu.wav b/app/src/main/resources/assets/audio/fx/mainmenu.wav
new file mode 100644
index 0000000..c0c3e25
Binary files /dev/null and b/app/src/main/resources/assets/audio/fx/mainmenu.wav differ
diff --git a/app/src/main/resources/assets/audio/fx/sadtrombone.wav b/app/src/main/resources/assets/audio/fx/sadtrombone.wav
new file mode 100644
index 0000000..7eb18e6
Binary files /dev/null and b/app/src/main/resources/assets/audio/fx/sadtrombone.wav differ
diff --git a/app/src/main/resources/assets/audio/fx/scawymusic.wav b/app/src/main/resources/assets/audio/fx/scawymusic.wav
new file mode 100644
index 0000000..9626b59
Binary files /dev/null and b/app/src/main/resources/assets/audio/fx/scawymusic.wav differ
diff --git a/app/src/main/resources/assets/audio/fx/suspensful.wav b/app/src/main/resources/assets/audio/fx/suspensful.wav
new file mode 100644
index 0000000..0d5ef27
Binary files /dev/null and b/app/src/main/resources/assets/audio/fx/suspensful.wav differ
diff --git a/app/src/main/resources/assets/audio/fx/testsound.wav b/app/src/main/resources/assets/audio/fx/testsound.wav
new file mode 100644
index 0000000..cf2a32f
Binary files /dev/null and b/app/src/main/resources/assets/audio/fx/testsound.wav differ
diff --git a/app/src/main/resources/assets/audio/fx/winsound.wav b/app/src/main/resources/assets/audio/fx/winsound.wav
new file mode 100644
index 0000000..d72a386
Binary files /dev/null and b/app/src/main/resources/assets/audio/fx/winsound.wav differ
diff --git a/app/src/main/resources/assets/audio/music/damned.mp3 b/app/src/main/resources/assets/audio/music/damned.mp3
new file mode 100644
index 0000000..4b0bfbc
Binary files /dev/null and b/app/src/main/resources/assets/audio/music/damned.mp3 differ
diff --git a/app/src/main/resources/assets/audio/music/extraction-point.mp3 b/app/src/main/resources/assets/audio/music/extraction-point.mp3
new file mode 100644
index 0000000..1fc6430
Binary files /dev/null and b/app/src/main/resources/assets/audio/music/extraction-point.mp3 differ
diff --git a/app/src/main/resources/assets/audio/music/godfrey.mp3 b/app/src/main/resources/assets/audio/music/godfrey.mp3
new file mode 100644
index 0000000..7cd1a30
Binary files /dev/null and b/app/src/main/resources/assets/audio/music/godfrey.mp3 differ
diff --git a/app/src/main/resources/assets/audio/music/mw2-main-menu.mp3 b/app/src/main/resources/assets/audio/music/mw2-main-menu.mp3
new file mode 100644
index 0000000..5228853
Binary files /dev/null and b/app/src/main/resources/assets/audio/music/mw2-main-menu.mp3 differ
diff --git a/app/src/main/resources/assets/fonts/GroovyManiac.ttf b/app/src/main/resources/assets/fonts/GroovyManiac.ttf
new file mode 100644
index 0000000..4202757
Binary files /dev/null and b/app/src/main/resources/assets/fonts/GroovyManiac.ttf differ
diff --git a/app/src/main/resources/assets/fonts/Roboto-Regular.ttf b/app/src/main/resources/assets/fonts/Roboto-Regular.ttf
new file mode 100644
index 0000000..7e3bb2f
Binary files /dev/null and b/app/src/main/resources/assets/fonts/Roboto-Regular.ttf differ
diff --git a/app/src/main/resources/image/lowpoly.png b/app/src/main/resources/assets/image/lowpoly.png
similarity index 100%
rename from app/src/main/resources/image/lowpoly.png
rename to app/src/main/resources/assets/image/lowpoly.png
diff --git a/app/src/main/resources/assets/localization/localization.properties b/app/src/main/resources/assets/localization/localization.properties
new file mode 100644
index 0000000..fcbc8ac
--- /dev/null
+++ b/app/src/main/resources/assets/localization/localization.properties
@@ -0,0 +1,17 @@
+# Window title
+windowTitle=ISY Games Selector
+
+# Main Menu buttons
+mainMenuSelectTicTacToe=Tic Tac Toe\u5426
+mainMenuSelectReversi=Reversi\u5426
+mainMenuSelectSudoku=Sudoku
+mainMenuSelectBattleship=Battleship
+mainMenuSelectOther=Other
+mainMenuSelectCredits=Credits
+mainMenuSelectOptions=Options
+mainMenuSelectQuit=Quit
+
+# Quit Menu text and buttons
+quitMenuTextSure=Are you sure?
+quitMenuButtonYes=Yes
+quitMenuButtonNo=No
\ No newline at end of file
diff --git a/app/src/main/resources/assets/localization/localization_de.properties b/app/src/main/resources/assets/localization/localization_de.properties
new file mode 100644
index 0000000..dc46278
--- /dev/null
+++ b/app/src/main/resources/assets/localization/localization_de.properties
@@ -0,0 +1,17 @@
+# Window title
+windowTitle=ISY Spiele-Auswahl
+
+# Main Menu buttons
+mainMenuSelectTicTacToe=Tic Tac Toe
+mainMenuSelectReversi=Reversi
+mainMenuSelectSudoku=Sudoku
+mainMenuSelectBattleship=Flottenman\u00F6ver
+mainMenuSelectOther=Andere
+mainMenuSelectCredits=Credits
+mainMenuSelectOptions=Optionen
+mainMenuSelectQuit=Beenden
+
+# Quit Menu text and buttons
+quitMenuTextSure=Bist du sicher?
+quitMenuButtonYes=Ja
+quitMenuButtonNo=Nein
\ No newline at end of file
diff --git a/app/src/main/resources/assets/localization/localization_es.properties b/app/src/main/resources/assets/localization/localization_es.properties
new file mode 100644
index 0000000..60439ab
--- /dev/null
+++ b/app/src/main/resources/assets/localization/localization_es.properties
@@ -0,0 +1,17 @@
+# Window title
+windowTitle=Selector de juegos ISY
+
+# Main Menu buttons
+mainMenuSelectTicTacToe=Tres en raya
+mainMenuSelectReversi=Reversi
+mainMenuSelectSudoku=Sudoku
+mainMenuSelectBattleship=Batalla naval
+mainMenuSelectOther=Otros
+mainMenuSelectCredits=Cr\u00E9ditos
+mainMenuSelectOptions=Opciones
+mainMenuSelectQuit=Salir
+
+# Quit Menu text and buttons
+quitMenuTextSure=\u00BFEst\u00E1s seguro?
+quitMenuButtonYes=S\u00ED
+quitMenuButtonNo=No
\ No newline at end of file
diff --git a/app/src/main/resources/assets/localization/localization_fr.properties b/app/src/main/resources/assets/localization/localization_fr.properties
new file mode 100644
index 0000000..75d9f4a
--- /dev/null
+++ b/app/src/main/resources/assets/localization/localization_fr.properties
@@ -0,0 +1,17 @@
+# Window title
+windowTitle=S\u00E9lecteur de jeux ISY
+
+# Main Menu buttons
+mainMenuSelectTicTacToe=Morpion
+mainMenuSelectReversi=Reversi
+mainMenuSelectSudoku=Sudoku
+mainMenuSelectBattleship=Bataille navale
+mainMenuSelectOther=Autres
+mainMenuSelectCredits=Cr\u00E9dits
+mainMenuSelectOptions=Options
+mainMenuSelectQuit=Quitter
+
+# Quit Menu text and buttons
+quitMenuTextSure=\u00CAtes-vous s\u00FBr?
+quitMenuButtonYes=Oui
+quitMenuButtonNo=Non
\ No newline at end of file
diff --git a/app/src/main/resources/assets/localization/localization_it.properties b/app/src/main/resources/assets/localization/localization_it.properties
new file mode 100644
index 0000000..75a8896
--- /dev/null
+++ b/app/src/main/resources/assets/localization/localization_it.properties
@@ -0,0 +1,17 @@
+# Window title
+windowTitle=Selettore giochi ISY
+
+# Main Menu buttons
+mainMenuSelectTicTacToe=Tris
+mainMenuSelectReversi=Reversi
+mainMenuSelectSudoku=Sudoku
+mainMenuSelectBattleship=Battaglia navale
+mainMenuSelectOther=Altro
+mainMenuSelectCredits=Crediti
+mainMenuSelectOptions=Opzioni
+mainMenuSelectQuit=Esci
+
+# Quit Menu text and buttons
+quitMenuTextSure=Sei sicuro?
+quitMenuButtonYes=S\u00EC
+quitMenuButtonNo=No
\ No newline at end of file
diff --git a/app/src/main/resources/assets/localization/localization_nl.properties b/app/src/main/resources/assets/localization/localization_nl.properties
new file mode 100644
index 0000000..4c3eb30
--- /dev/null
+++ b/app/src/main/resources/assets/localization/localization_nl.properties
@@ -0,0 +1,17 @@
+# Window title
+windowTitle=ISY Spellen Kiezer
+
+# Main Menu buttons
+mainMenuSelectTicTacToe=Boter Kaas En Eieren
+mainMenuSelectReversi=Reversi
+mainMenuSelectSudoku=Sudoku
+mainMenuSelectBattleship=Zeeslag
+mainMenuSelectOther=Anders
+mainMenuSelectCredits=Credits
+mainMenuSelectOptions=Opties
+mainMenuSelectQuit=Afsluiten
+
+# Quit Menu text and buttons
+quitMenuTextSure=Weet je het zeker?
+quitMenuButtonYes=Ja
+quitMenuButtonNo=Nee
\ No newline at end of file
diff --git a/app/src/main/resources/assets/localization/localization_zh.properties b/app/src/main/resources/assets/localization/localization_zh.properties
new file mode 100644
index 0000000..f703670
--- /dev/null
+++ b/app/src/main/resources/assets/localization/localization_zh.properties
@@ -0,0 +1,30 @@
+# suppress inspection "LossyEncoding" for whole file
+# Window title
+windowTitle=ISY \u6E38\u620F\u9009\u62E9\u5668
+# ?????
+
+# Main Menu buttons
+mainMenuSelectTicTacToe=\u4E95\u5B57\u68CB
+# ???
+mainMenuSelectReversi=\u9ED1\u767D\u68CB
+# ???
+mainMenuSelectSudoku=\u6570\u72EC
+# ??
+mainMenuSelectBattleship=\u6D77\u6218\u68CB
+# ???
+mainMenuSelectOther=\u5176\u4ED6
+# ??
+mainMenuSelectCredits=\u5236\u4F5C\u4EBA\u5458
+# ????
+mainMenuSelectOptions=\u9009\u9879
+# ??
+mainMenuSelectQuit=\u9000\u51FA
+# ??
+
+# Quit Menu text and buttons
+quitMenuTextSure=\u4F60\u786E\u5B9A\u5417\uFF1F
+# ?????
+quitMenuButtonYes=\u662F
+# ?
+quitMenuButtonNo=\u5426
+# ?
\ No newline at end of file
diff --git a/app/src/main/resources/style/app.css b/app/src/main/resources/assets/style/app.css
similarity index 100%
rename from app/src/main/resources/style/app.css
rename to app/src/main/resources/assets/style/app.css
diff --git a/app/src/main/resources/style/quit.css b/app/src/main/resources/assets/style/quit.css
similarity index 100%
rename from app/src/main/resources/style/quit.css
rename to app/src/main/resources/assets/style/quit.css
diff --git a/app/src/main/resources/assets/text/test.txt b/app/src/main/resources/assets/text/test.txt
new file mode 100644
index 0000000..9e44f93
--- /dev/null
+++ b/app/src/main/resources/assets/text/test.txt
@@ -0,0 +1 @@
+Super gaaf!
\ No newline at end of file
diff --git a/framework/pom.xml b/framework/pom.xml
index e924481..b32b25c 100644
--- a/framework/pom.xml
+++ b/framework/pom.xml
@@ -13,6 +13,12 @@
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ 2.46.1
+
+
io.nettynetty-all
@@ -90,6 +96,27 @@
4.0.0
+
+ org.openjfx
+ javafx-controls
+ 25
+
+
+
+
+ org.openjfx
+ javafx-media
+ 25
+
+
+
+
+
+ org.reflections
+ reflections
+ 0.10.2
+
+
@@ -103,24 +130,41 @@
2525UTF-8
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ 2.46.1
+
+
+ origin/main
+
+
+
+
+
+ .gitattributes
+ .gitignore
+
+
+
+
+
+ true
+ 4
+
+
+
+
+
+
+ 1.28.0
+
+ true
+ true
+
+
diff --git a/framework/src/main/java/org/toop/framework/SnowflakeGenerator.java b/framework/src/main/java/org/toop/framework/SnowflakeGenerator.java
new file mode 100644
index 0000000..7fbb946
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/SnowflakeGenerator.java
@@ -0,0 +1,159 @@
+package org.toop.framework;
+
+import java.net.NetworkInterface;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * A thread-safe, distributed unique ID generator following the Snowflake pattern.
+ *
+ * Each generated 64-bit ID encodes:
+ *
+ *
41-bit timestamp (milliseconds since custom epoch)
+ *
10-bit machine identifier
+ *
12-bit sequence number for IDs generated in the same millisecond
+ *
+ *
+ *
+ *
This implementation ensures:
+ *
+ *
IDs are unique per machine.
+ *
Monotonicity within the same machine.
+ *
Safe concurrent generation via synchronized {@link #nextId()}.
+ *
+ *
+ *
+ *
Custom epoch is set to {@code 2025-01-01T00:00:00Z}.
+ *
+ *
Usage example:
+ *
{@code
+ * SnowflakeGenerator generator = new SnowflakeGenerator();
+ * long id = generator.nextId();
+ * }
+ */
+public class SnowflakeGenerator {
+
+ /**
+ * Custom epoch in milliseconds (2025-01-01T00:00:00Z).
+ */
+ private static final long EPOCH = Instant.parse("2025-01-01T00:00:00Z").toEpochMilli();
+
+ // Bit allocations
+ private static final long TIMESTAMP_BITS = 41;
+ private static final long MACHINE_BITS = 10;
+ private static final long SEQUENCE_BITS = 12;
+
+ // Maximum values for each component
+ private static final long MAX_MACHINE_ID = (1L << MACHINE_BITS) - 1;
+ private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;
+ private static final long MAX_TIMESTAMP = (1L << TIMESTAMP_BITS) - 1;
+
+ // Bit shifts for composing the ID
+ private static final long MACHINE_SHIFT = SEQUENCE_BITS;
+ private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + MACHINE_BITS;
+
+ /**
+ * Unique machine identifier derived from network interfaces (10 bits).
+ */
+ private static final long machineId = SnowflakeGenerator.genMachineId();
+
+ private final AtomicLong lastTimestamp = new AtomicLong(-1L);
+ private long sequence = 0L;
+
+ /**
+ * Generates a 10-bit machine identifier based on MAC addresses of network interfaces.
+ * Falls back to a random value if MAC cannot be determined.
+ */
+ private static long genMachineId() {
+ try {
+ StringBuilder sb = new StringBuilder();
+ for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
+ byte[] mac = ni.getHardwareAddress();
+ if (mac != null) {
+ for (byte b : mac) sb.append(String.format("%02X", b));
+ }
+ }
+ return sb.toString().hashCode() & 0x3FF; // limit to 10 bits
+ } catch (Exception e) {
+ return (long) (Math.random() * 1024); // fallback
+ }
+ }
+
+ /**
+ * For testing: manually set the last generated timestamp.
+ * @param l timestamp in milliseconds
+ */
+ void setTime(long l) {
+ this.lastTimestamp.set(l);
+ }
+
+ /**
+ * Constructs a SnowflakeGenerator.
+ * Validates that the machine ID is within allowed range.
+ * @throws IllegalArgumentException if machine ID is invalid
+ */
+ public SnowflakeGenerator() {
+ if (machineId < 0 || machineId > MAX_MACHINE_ID) {
+ throw new IllegalArgumentException(
+ "Machine ID must be between 0 and " + MAX_MACHINE_ID);
+ }
+ }
+
+ /**
+ * Generates the next unique ID.
+ *
+ * If multiple IDs are generated in the same millisecond, a sequence number
+ * is incremented. If the sequence overflows, waits until the next millisecond.
+ *
+ *
+ * @return a unique 64-bit ID
+ * @throws IllegalStateException if clock moves backwards or timestamp exceeds 41-bit limit
+ */
+ public synchronized long nextId() {
+ long currentTimestamp = timestamp();
+
+ if (currentTimestamp < lastTimestamp.get()) {
+ throw new IllegalStateException("Clock moved backwards. Refusing to generate id.");
+ }
+
+ if (currentTimestamp > MAX_TIMESTAMP) {
+ throw new IllegalStateException("Timestamp bits overflow, Snowflake expired.");
+ }
+
+ if (currentTimestamp == lastTimestamp.get()) {
+ sequence = (sequence + 1) & MAX_SEQUENCE;
+ if (sequence == 0) {
+ currentTimestamp = waitNextMillis(currentTimestamp);
+ }
+ } else {
+ sequence = 0L;
+ }
+
+ lastTimestamp.set(currentTimestamp);
+
+ return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT)
+ | (machineId << MACHINE_SHIFT)
+ | sequence;
+ }
+
+ /**
+ * Waits until the next millisecond if sequence overflows.
+ * @param lastTimestamp previous timestamp
+ * @return new timestamp
+ */
+ private long waitNextMillis(long lastTimestamp) {
+ long ts = timestamp();
+ while (ts <= lastTimestamp) {
+ ts = timestamp();
+ }
+ return ts;
+ }
+
+ /**
+ * Returns current system timestamp in milliseconds.
+ */
+ private long timestamp() {
+ return System.currentTimeMillis();
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/Asset.java b/framework/src/main/java/org/toop/framework/asset/Asset.java
new file mode 100644
index 0000000..9f1f488
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/Asset.java
@@ -0,0 +1,29 @@
+package org.toop.framework.asset;
+
+import org.toop.framework.SnowflakeGenerator;
+import org.toop.framework.asset.resources.BaseResource;
+
+public class Asset {
+ private final Long id;
+ private final String name;
+ private final T resource;
+
+ public Asset(String name, T resource) {
+ this.id = new SnowflakeGenerator().nextId();
+ this.name = name;
+ this.resource = resource;
+ }
+
+ public Long getId() {
+ return this.id;
+ }
+
+ public String getName() {
+ return this.name;
+ }
+
+ public T getResource() {
+ return this.resource;
+ }
+
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/AssetLoader.java b/framework/src/main/java/org/toop/framework/asset/AssetLoader.java
new file mode 100644
index 0000000..93ac189
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/AssetLoader.java
@@ -0,0 +1,246 @@
+package org.toop.framework.asset;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.toop.framework.asset.events.AssetLoaderEvents;
+import org.toop.framework.asset.resources.*;
+import org.toop.framework.eventbus.EventFlow;
+import org.reflections.Reflections;
+
+import java.io.File;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+
+/**
+ * Responsible for loading assets from a file system directory into memory.
+ *
+ * The {@code AssetLoader} scans a root folder recursively, identifies files,
+ * and maps them to registered resource types based on file extensions and
+ * {@link FileExtension} annotations.
+ * It supports multiple resource types including {@link PreloadResource} (automatically loaded)
+ * and {@link BundledResource} (merged across multiple files).
+ *
+ *
+ *
Assets are stored in a static, thread-safe list and can be retrieved
+ * through {@link AssetManager}.
+ *
+ *
Features:
+ *
+ *
Recursive directory scanning for assets.
+ *
Automatic registration of resource classes via reflection.
+ *
Bundled resource support: multiple files merged into a single resource instance.
+ */
+public class AssetLoader {
+ private static final Logger logger = LogManager.getLogger(AssetLoader.class);
+ private static final List> assets = new CopyOnWriteArrayList<>();
+ private final Map> registry = new ConcurrentHashMap<>();
+
+ private final AtomicInteger loadedCount = new AtomicInteger(0);
+ private int totalCount = 0;
+
+ /**
+ * Constructs an AssetLoader and loads assets from the given root folder.
+ * @param rootFolder the folder containing asset files
+ */
+ public AssetLoader(File rootFolder) {
+ autoRegisterResources();
+ List foundFiles = new ArrayList<>();
+ fileSearcher(rootFolder, foundFiles);
+ this.totalCount = foundFiles.size();
+
+ // measure memory before loading
+ long before = getUsedMemory();
+
+ loader(foundFiles);
+
+ // ~measure memory after loading
+ long after = getUsedMemory();
+ long used = after - before;
+
+ logger.info("Total files loaded: {}", this.totalCount);
+ logger.info("Memory used by assets: ~{} MB", used / (1024 * 1024));
+ }
+
+ private static long getUsedMemory() {
+ Runtime runtime = Runtime.getRuntime();
+ return runtime.totalMemory() - runtime.freeMemory();
+ }
+
+ /**
+ * Constructs an AssetLoader from a folder path.
+ * @param rootFolder the folder path containing assets
+ */
+ public AssetLoader(String rootFolder) {
+ this(new File(rootFolder));
+ }
+
+ /**
+ * Returns the current progress of loading assets (0.0 to 1.0).
+ * @return progress as a double
+ */
+ public double getProgress() {
+ return (totalCount == 0) ? 1.0 : (loadedCount.get() / (double) totalCount);
+ }
+
+ /**
+ * Returns the number of assets loaded so far.
+ * @return loaded count
+ */
+ public int getLoadedCount() {
+ return loadedCount.get();
+ }
+
+ /**
+ * Returns the total number of files found to load.
+ * @return total asset count
+ */
+ public int getTotalCount() {
+ return totalCount;
+ }
+
+ /**
+ * Returns a snapshot list of all assets loaded by this loader.
+ * @return list of loaded assets
+ */
+ public List> getAssets() {
+ return new ArrayList<>(assets);
+ }
+
+ /**
+ * Registers a factory for a specific file extension.
+ * @param extension the file extension (without dot)
+ * @param factory a function mapping a File to a resource instance
+ * @param the type of resource
+ */
+ public void register(String extension, Function factory) {
+ this.registry.put(extension, factory);
+ }
+
+ /**
+ * Maps a file to a resource instance based on its extension and registered factories.
+ */
+ private T resourceMapper(File file, Class type) {
+ String ext = getExtension(file.getName());
+ Function factory = registry.get(ext);
+ if (factory == null) return null;
+
+ BaseResource resource = factory.apply(file);
+
+ if (!type.isInstance(resource)) {
+ throw new IllegalArgumentException(
+ "File " + file.getName() + " is not of type " + type.getSimpleName()
+ );
+ }
+
+ return type.cast(resource);
+ }
+
+ /**
+ * Loads the given list of files into assets, handling bundled and preload resources.
+ */
+ private void loader(List files) {
+ Map bundledResources = new HashMap<>();
+
+ for (File file : files) {
+ BaseResource resource = resourceMapper(file, BaseResource.class);
+ switch (resource) {
+ case null -> {
+ continue;
+ }
+ case BundledResource br -> {
+ String key = resource.getClass().getName() + ":" + br.getBaseName();
+ if (bundledResources.containsKey(key)) {
+ bundledResources.get(key).loadFile(file);
+ resource = (BaseResource) bundledResources.get(key);
+ } else {
+ br.loadFile(file);
+ bundledResources.put(key, br);
+ }
+ }
+ case PreloadResource pr -> pr.load();
+ default -> {
+ }
+ }
+
+ BaseResource finalResource = resource;
+ boolean alreadyAdded = assets.stream()
+ .anyMatch(a -> a.getResource() == finalResource);
+ if (!alreadyAdded) {
+ assets.add(new Asset<>(file.getName(), resource));
+ }
+
+ logger.info("Loaded {} from {}", resource.getClass().getSimpleName(), file.getAbsolutePath());
+ loadedCount.incrementAndGet();
+ new EventFlow()
+ .addPostEvent(new AssetLoaderEvents.LoadingProgressUpdate(loadedCount.get(), totalCount))
+ .postEvent();
+ }
+ }
+
+ /**
+ * Recursively searches a folder and adds all files to the foundFiles list.
+ */
+ private void fileSearcher(final File folder, List foundFiles) {
+ for (File fileEntry : Objects.requireNonNull(folder.listFiles())) {
+ if (fileEntry.isDirectory()) {
+ fileSearcher(fileEntry, foundFiles);
+ } else {
+ foundFiles.add(fileEntry);
+ }
+ }
+ }
+
+ /**
+ * Uses reflection to automatically register all {@link BaseResource} subclasses
+ * annotated with {@link FileExtension}.
+ */
+ private void autoRegisterResources() {
+ Reflections reflections = new Reflections("org.toop.framework.asset.resources");
+ Set> classes = reflections.getSubTypesOf(BaseResource.class);
+
+ for (Class extends BaseResource> cls : classes) {
+ if (!cls.isAnnotationPresent(FileExtension.class)) continue;
+ FileExtension annotation = cls.getAnnotation(FileExtension.class);
+ for (String ext : annotation.value()) {
+ registry.put(ext, file -> {
+ try {
+ return cls.getConstructor(File.class).newInstance(file);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Extracts the base name from a file name, used for bundling multiple files.
+ */
+ private static String getBaseName(String fileName) {
+ int underscoreIndex = fileName.indexOf('_');
+ int dotIndex = fileName.lastIndexOf('.');
+ if (underscoreIndex > 0) return fileName.substring(0, underscoreIndex);
+ return fileName.substring(0, dotIndex);
+ }
+
+ /**
+ * Returns the file extension of a given file name (without dot).
+ */
+ public static String getExtension(String name) {
+ int i = name.lastIndexOf('.');
+ return (i > 0) ? name.substring(i + 1) : "";
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/AssetManager.java b/framework/src/main/java/org/toop/framework/asset/AssetManager.java
new file mode 100644
index 0000000..1630ae0
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/AssetManager.java
@@ -0,0 +1,150 @@
+package org.toop.framework.asset;
+
+import org.toop.framework.asset.resources.*;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Centralized manager for all loaded assets in the application.
+ *
+ * {@code AssetManager} maintains a thread-safe registry of {@link Asset} objects
+ * and provides utility methods to retrieve assets by name, ID, or type.
+ * It works together with {@link AssetLoader} to register assets automatically
+ * when they are loaded from the file system.
+ *
+ *
+ *
Key responsibilities:
+ *
+ *
Storing all loaded assets in a concurrent map.
+ *
Providing typed access to asset resources.
+ *
Allowing lookup by asset name or ID.
+ *
Supporting retrieval of all assets of a specific {@link BaseResource} subclass.
+ *
+ *
+ *
Example usage:
+ *
{@code
+ * // Load assets from a loader
+ * AssetLoader loader = new AssetLoader(new File("RootFolder"));
+ * AssetManager.loadAssets(loader);
+ *
+ * // Retrieve a single resource
+ * ImageAsset background = AssetManager.get("background.jpg");
+ *
+ * // Retrieve all fonts
+ * List> fonts = AssetManager.getAllOfType(FontAsset.class);
+ *
+ * // Retrieve by asset name or optional lookup
+ * Optional> maybeAsset = AssetManager.findByName("menu.css");
+ * }
+ *
+ *
Notes:
+ *
+ *
All retrieval methods are static and thread-safe.
+ *
The {@link #get(String)} method may require casting if the asset type is not known at compile time.
+ *
Assets should be loaded via {@link AssetLoader} before retrieval.
+ *
+ */
+public class AssetManager {
+ private static final AssetManager INSTANCE = new AssetManager();
+ private static final Map> assets = new ConcurrentHashMap<>();
+
+ private AssetManager() {}
+
+ /**
+ * Returns the singleton instance of {@code AssetManager}.
+ *
+ * @return the shared instance
+ */
+ public static AssetManager getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Loads all assets from a given {@link AssetLoader} into the manager.
+ *
+ * @param loader the loader that has already loaded assets
+ */
+ public synchronized static void loadAssets(AssetLoader loader) {
+ for (var asset : loader.getAssets()) {
+ assets.put(asset.getName(), asset);
+ }
+ }
+
+ /**
+ * Retrieve the resource of a given name, cast to the expected type.
+ *
+ * @param name the asset name
+ * @param the expected resource type
+ * @return the resource, or null if not found
+ */
+ @SuppressWarnings("unchecked")
+ public static T get(String name) {
+ Asset asset = (Asset) assets.get(name);
+ if (asset == null) return null;
+ return asset.getResource();
+ }
+
+ /**
+ * Retrieve all assets of a specific resource type.
+ *
+ * @param type the class type to filter
+ * @param the resource type
+ * @return a list of assets matching the type
+ */
+ public static ArrayList> getAllOfType(Class type) {
+ ArrayList> list = new ArrayList<>();
+ for (Asset extends BaseResource> asset : assets.values()) {
+ if (type.isInstance(asset.getResource())) {
+ @SuppressWarnings("unchecked")
+ Asset typed = (Asset) asset;
+ list.add(typed);
+ }
+ }
+ return list;
+ }
+
+ /**
+ * Retrieve an asset by its unique ID.
+ *
+ * @param id the asset ID
+ * @return the asset, or null if not found
+ */
+ public static Asset extends BaseResource> getById(String id) {
+ for (Asset extends BaseResource> asset : assets.values()) {
+ if (asset.getId().toString().equals(id)) {
+ return asset;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Retrieve an asset by its name.
+ *
+ * @param name the asset name
+ * @return the asset, or null if not found
+ */
+ public static Asset extends BaseResource> getByName(String name) {
+ return assets.get(name);
+ }
+
+ /**
+ * Attempt to find an asset by name, returning an {@link Optional}.
+ *
+ * @param name the asset name
+ * @return an Optional containing the asset if found
+ */
+ public static Optional> findByName(String name) {
+ return Optional.ofNullable(assets.get(name));
+ }
+
+ /**
+ * Add a new asset to the manager.
+ *
+ * @param asset the asset to add
+ */
+ public static void addAsset(Asset extends BaseResource> asset) {
+ assets.put(asset.getName(), asset);
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/events/AssetLoaderEvents.java b/framework/src/main/java/org/toop/framework/asset/events/AssetLoaderEvents.java
new file mode 100644
index 0000000..91a296e
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/events/AssetLoaderEvents.java
@@ -0,0 +1,7 @@
+package org.toop.framework.asset.events;
+
+import org.toop.framework.eventbus.events.EventWithoutSnowflake;
+
+public class AssetLoaderEvents {
+ public record LoadingProgressUpdate(int hasLoadedAmount, int isLoadingAmount) implements EventWithoutSnowflake {}
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/BaseResource.java b/framework/src/main/java/org/toop/framework/asset/resources/BaseResource.java
new file mode 100644
index 0000000..c1aa040
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/BaseResource.java
@@ -0,0 +1,18 @@
+package org.toop.framework.asset.resources;
+
+import java.io.*;
+
+public abstract class BaseResource {
+
+ final File file;
+ boolean isLoaded = false;
+
+ protected BaseResource(final File file) {
+ this.file = file;
+ }
+
+ public File getFile() {
+ return this.file;
+ }
+
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/BundledResource.java b/framework/src/main/java/org/toop/framework/asset/resources/BundledResource.java
new file mode 100644
index 0000000..b6559b8
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/BundledResource.java
@@ -0,0 +1,67 @@
+package org.toop.framework.asset.resources;
+
+import java.io.File;
+
+/**
+ * Represents a resource that can be composed of multiple files, or "bundled" together
+ * under a common base name.
+ *
+ *
Implementing classes allow an {@link org.toop.framework.asset.AssetLoader}
+ * 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.
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();
+}
\ No newline at end of file
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/CssAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/CssAsset.java
new file mode 100644
index 0000000..367fe80
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/CssAsset.java
@@ -0,0 +1,17 @@
+package org.toop.framework.asset.resources;
+
+import java.io.File;
+
+@FileExtension({"css"})
+public class CssAsset extends BaseResource {
+ private final String url;
+
+ public CssAsset(File file) {
+ super(file);
+ this.url = file.toURI().toString();
+ }
+
+ public String getUrl() {
+ return url;
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/FileExtension.java b/framework/src/main/java/org/toop/framework/asset/resources/FileExtension.java
new file mode 100644
index 0000000..1beb405
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/FileExtension.java
@@ -0,0 +1,41 @@
+package org.toop.framework.asset.resources;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.annotation.ElementType;
+
+/**
+ * Annotation to declare which file extensions a {@link BaseResource} subclass
+ * can handle.
+ *
+ *
This annotation is processed by the {@link org.toop.framework.asset.AssetLoader}
+ * 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.
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/resources/FontAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/FontAsset.java
new file mode 100644
index 0000000..4835f0f
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/FontAsset.java
@@ -0,0 +1,60 @@
+package org.toop.framework.asset.resources;
+
+import javafx.scene.text.Font;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+@FileExtension({"ttf", "otf"})
+public class FontAsset extends BaseResource implements PreloadResource {
+ private String family;
+
+ public FontAsset(final File fontFile) {
+ super(fontFile);
+ }
+
+ @Override
+ public void load() {
+ if (!this.isLoaded) {
+ try (FileInputStream fis = new FileInputStream(this.file)) {
+ // Register font with JavaFX
+ Font font = Font.loadFont(fis, 12); // Default preview size
+ if (font == null) {
+ throw new RuntimeException("Failed to load font: " + this.file);
+ }
+ this.family = font.getFamily(); // Save family name for CSS / future use
+ this.isLoaded = true;
+ } catch (IOException e) {
+ throw new RuntimeException("Error reading font file: " + this.file, e);
+ }
+ }
+ }
+
+ @Override
+ public void unload() {
+ // Font remains globally registered with JavaFX, but we just forget it locally
+ this.family = null;
+ this.isLoaded = false;
+ }
+
+ @Override
+ public boolean isLoaded() {
+ return this.isLoaded;
+ }
+
+ /** Get a new font instance with the given size */
+ public Font getFont(double size) {
+ if (!this.isLoaded) {
+ load();
+ }
+ return Font.font(this.family, size);
+ }
+
+ /** Get the family name (for CSS usage) */
+ public String getFamily() {
+ if (!this.isLoaded) {
+ load();
+ }
+ return this.family;
+ }
+}
\ No newline at end of file
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/ImageAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/ImageAsset.java
new file mode 100644
index 0000000..3cc0a3b
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/ImageAsset.java
@@ -0,0 +1,45 @@
+package org.toop.framework.asset.resources;
+
+import javafx.scene.image.Image;
+import java.io.File;
+import java.io.FileInputStream;
+
+@FileExtension({"png", "jpg", "jpeg"})
+public class ImageAsset extends BaseResource implements LoadableResource {
+ private Image image;
+
+ public ImageAsset(final File file) {
+ super(file);
+ }
+
+ @Override
+ public void load() {
+ if (!this.isLoaded) {
+ try (FileInputStream fis = new FileInputStream(this.file)) {
+ this.image = new Image(fis);
+ this.isLoaded = true;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to load image: " + this.file, e);
+ }
+ }
+ }
+
+ @Override
+ public void unload() {
+ this.image = null;
+ this.isLoaded = false;
+ }
+
+ @Override
+ public boolean isLoaded() {
+ return this.isLoaded;
+ }
+
+ public Image getImage() {
+ if (!this.isLoaded) {
+ this.load();
+ return image;
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/LoadableResource.java b/framework/src/main/java/org/toop/framework/asset/resources/LoadableResource.java
new file mode 100644
index 0000000..69374f7
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/LoadableResource.java
@@ -0,0 +1,64 @@
+package org.toop.framework.asset.resources;
+
+/**
+ * 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.
This interface is commonly used with {@link PreloadResource} to allow automatic
+ * loading by an {@link org.toop.framework.asset.AssetLoader} 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/resources/LocalizationAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/LocalizationAsset.java
new file mode 100644
index 0000000..39862c4
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/LocalizationAsset.java
@@ -0,0 +1,90 @@
+package org.toop.framework.asset.resources;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+@FileExtension({"properties"})
+public class LocalizationAsset extends BaseResource implements LoadableResource, BundledResource {
+ private final Map bundles = new HashMap<>();
+ private boolean isLoaded = false;
+ private final Locale fallback = Locale.forLanguageTag("");
+
+ public LocalizationAsset(File file) {
+ super(file);
+ }
+
+ @Override
+ public void load() {
+ loadFile(getFile());
+ isLoaded = true;
+ }
+
+ @Override
+ public void unload() {
+ bundles.clear();
+ isLoaded = false;
+ }
+
+ @Override
+ public boolean isLoaded() {
+ return isLoaded;
+ }
+
+ public String getString(String key, Locale locale) {
+ Locale target = findBestLocale(locale);
+ ResourceBundle bundle = bundles.get(target);
+ if (bundle == null) throw new MissingResourceException(
+ "No bundle for locale: " + target, getClass().getName(), key);
+ return bundle.getString(key);
+ }
+
+ private Locale findBestLocale(Locale locale) {
+ if (bundles.containsKey(locale)) return locale;
+ for (Locale l : bundles.keySet()) {
+ if (l.getLanguage().equals(locale.getLanguage())) return l;
+ }
+ return fallback;
+ }
+
+ public Set getAvailableLocales() {
+ return Collections.unmodifiableSet(bundles.keySet());
+ }
+
+ @Override
+ public void loadFile(File file) {
+ String baseName = getBaseName(file.getName());
+ try (InputStreamReader reader =
+ new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) {
+ Locale locale = extractLocale(file.getName(), baseName);
+ bundles.put(locale, new PropertyResourceBundle(reader));
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to load localization file: " + file, e);
+ }
+ isLoaded = true;
+ }
+
+ @Override
+ public String getBaseName() {
+ return getBaseName(getFile().getName());
+ }
+
+ private String getBaseName(String fileName) {
+ int underscoreIndex = fileName.indexOf('_');
+ int dotIndex = fileName.lastIndexOf('.');
+ if (underscoreIndex > 0) {
+ return fileName.substring(0, underscoreIndex);
+ }
+ return fileName.substring(0, dotIndex);
+ }
+
+ private Locale extractLocale(String fileName, String baseName) {
+ int underscoreIndex = fileName.indexOf('_');
+ int dotIndex = fileName.lastIndexOf('.');
+ if (underscoreIndex > 0 && dotIndex > underscoreIndex) {
+ String localePart = fileName.substring(underscoreIndex + 1, dotIndex);
+ return Locale.forLanguageTag(localePart.replace('_', '-'));
+ }
+ return fallback;
+ }
+}
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/MusicAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/MusicAsset.java
new file mode 100644
index 0000000..87c27f6
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/MusicAsset.java
@@ -0,0 +1,38 @@
+package org.toop.framework.asset.resources;
+
+import javafx.scene.media.Media;
+
+import java.io.*;
+
+@FileExtension({"mp3"})
+public class MusicAsset extends BaseResource implements LoadableResource {
+ private Media media;
+
+ public MusicAsset(final File audioFile) {
+ super(audioFile);
+ }
+
+ public Media getMedia() {
+ if (media == null) {
+ media = new Media(file.toURI().toString());
+ }
+ return media;
+ }
+
+ @Override
+ public void load() {
+ if (media == null) media = new Media(file.toURI().toString());
+ this.isLoaded = true;
+ }
+
+ @Override
+ public void unload() {
+ media = null;
+ isLoaded = false;
+ }
+
+ @Override
+ public boolean isLoaded() {
+ return isLoaded;
+ }
+}
\ No newline at end of file
diff --git a/framework/src/main/java/org/toop/framework/asset/resources/PreloadResource.java b/framework/src/main/java/org/toop/framework/asset/resources/PreloadResource.java
new file mode 100644
index 0000000..6458751
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/PreloadResource.java
@@ -0,0 +1,37 @@
+package org.toop.framework.asset.resources;
+
+/**
+ * Marker interface for resources that should be **automatically loaded** by the {@link org.toop.framework.asset.AssetLoader}.
+ *
+ *
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 AssetLoader} 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/asset/resources/SoundEffectAsset.java b/framework/src/main/java/org/toop/framework/asset/resources/SoundEffectAsset.java
new file mode 100644
index 0000000..c9c81a8
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/SoundEffectAsset.java
@@ -0,0 +1,54 @@
+package org.toop.framework.asset.resources;
+
+import javafx.scene.media.Media;
+
+import javax.sound.sampled.*;
+import java.io.*;
+import java.net.URI;
+
+@FileExtension({"wav"})
+public class SoundEffectAsset extends BaseResource implements LoadableResource {
+
+ public SoundEffectAsset(final File audioFile) {
+ super(audioFile);
+ }
+
+ // Gets a new clip to play
+ public Clip getNewClip() throws LineUnavailableException, UnsupportedAudioFileException, IOException {
+ if(!this.isLoaded()){
+ this.load();
+ }
+
+ // Get a new clip from audio system
+ Clip clip = AudioSystem.getClip();
+
+ // Insert a new audio stream into the clip
+ clip.open(this.getAudioStream());
+ return clip;
+ }
+
+ // Generates a new audio stream from byte array
+ private AudioInputStream getAudioStream() throws UnsupportedAudioFileException, IOException {
+ return AudioSystem.getAudioInputStream(this.file);
+ }
+
+ @Override
+ public void load() {
+ try {
+ this.getAudioStream();
+ this.isLoaded = true;
+ } catch (UnsupportedAudioFileException | IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public void unload() {
+ this.isLoaded = false; // TODO?
+ }
+
+ @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..8fc51bc
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/asset/resources/TextAsset.java
@@ -0,0 +1,41 @@
+package org.toop.framework.asset.resources;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+@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;
+ }
+}
\ No newline at end of file
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..6a508cc
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/audio/SoundManager.java
@@ -0,0 +1,179 @@
+package org.toop.framework.audio;
+
+import javafx.application.Platform;
+import javafx.scene.media.MediaPlayer;
+import org.toop.framework.SnowflakeGenerator;
+import org.toop.framework.asset.Asset;
+import org.toop.framework.asset.AssetManager;
+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;
+
+import java.io.*;
+import java.util.*;
+import javax.sound.sampled.*;
+
+public class SoundManager {
+ 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 double volume = 1.0;
+
+ public SoundManager() {
+ // Get all Audio Resources and add them to a list.
+ for (Asset asset : AssetManager.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(this::handleVolumeChange)
+ .listen(AudioEvents.playOnClickButton.class, _ -> {
+ try {
+ playSound("hitsound0.wav", false);
+ } catch (UnsupportedAudioFileException | LineUnavailableException | IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ private void handlePlaySound(AudioEvents.PlayAudio event) {
+ try {
+ this.playSound(event.fileName(), event.loop());
+ } catch (UnsupportedAudioFileException | LineUnavailableException | IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void handleStopSound(AudioEvents.StopAudio event) {
+ this.stopSound(event.clipId());
+ }
+
+ private void addAudioResource(Asset audioAsset)
+ throws IOException, UnsupportedAudioFileException, LineUnavailableException {
+
+ this.audioResources.put(audioAsset.getName(), audioAsset.getResource());
+ }
+
+ private void handleVolumeChange(AudioEvents.ChangeVolume event) {
+ if (event.newVolume() > 1.0) this.volume = 1.0;
+ else this.volume = Math.max(event.newVolume(), 0.0);
+ for (MediaPlayer mediaPlayer : this.activeMusic) {
+ mediaPlayer.setVolume(this.volume);
+ }
+ }
+
+ private void handleMusicStart(AudioEvents.StartBackgroundMusic e) {
+ backgroundMusicQueue.clear();
+ Platform.runLater(() -> {
+ backgroundMusicQueue.addAll(
+ AssetManager.getAllOfType(MusicAsset.class).stream()
+ .map(Asset::getResource)
+ .toList()
+ );
+ 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();
+ });
+
+ mediaPlayer.setVolume(this.volume);
+ mediaPlayer.play();
+ activeMusic.add(mediaPlayer);
+ }
+
+ 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){
+ return -1;
+ }
+
+ // Get a new clip from resource
+ Clip clip = asset.getNewClip();
+
+ // If supposed to loop make it loop, else just start it once
+ if (loop) {
+ clip.loop(Clip.LOOP_CONTINUOUSLY);
+ }
+ else {
+ clip.start();
+ }
+
+ // 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();
+ }
+}
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..11ca3df
--- /dev/null
+++ b/framework/src/main/java/org/toop/framework/audio/events/AudioEvents.java
@@ -0,0 +1,17 @@
+package org.toop.framework.audio.events;
+
+import org.toop.framework.asset.resources.MusicAsset;
+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 PlayAudio(String fileName, boolean loop)
+ implements EventWithoutSnowflake {}
+
+ public record StopAudio(long clipId) implements EventWithoutSnowflake {}
+
+ public record StartBackgroundMusic() implements EventWithoutSnowflake {}
+ public record ChangeVolume(double newVolume) implements EventWithoutSnowflake {}
+ public record playOnClickButton() implements EventWithoutSnowflake {}
+}
diff --git a/framework/src/main/java/org/toop/framework/eventbus/EventFlow.java b/framework/src/main/java/org/toop/framework/eventbus/EventFlow.java
index 5d71f61..4c4a8de 100644
--- a/framework/src/main/java/org/toop/framework/eventbus/EventFlow.java
+++ b/framework/src/main/java/org/toop/framework/eventbus/EventFlow.java
@@ -1,24 +1,26 @@
package org.toop.framework.eventbus;
-import org.toop.framework.eventbus.events.EventType;
-import org.toop.framework.eventbus.events.EventWithSnowflake;
-import org.toop.framework.eventbus.SnowflakeGenerator;
-
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
+import java.util.function.Supplier;
+import org.toop.framework.SnowflakeGenerator;
+import org.toop.framework.eventbus.events.EventType;
+import org.toop.framework.eventbus.events.EventWithSnowflake;
/**
- * EventFlow is a utility class for creating, posting, and optionally subscribing to events
- * in a type-safe and chainable manner. It is designed to work with the {@link GlobalEventBus}.
+ * EventFlow is a utility class for creating, posting, and optionally subscribing to events in a
+ * type-safe and chainable manner. It is designed to work with the {@link GlobalEventBus}.
*
- *
This class supports automatic UUID assignment for {@link EventWithSnowflake} events,
- * and allows filtering subscribers so they only respond to events with a specific UUID.
- * All subscription methods are chainable, and you can configure automatic unsubscription
- * after an event has been successfully handled.
+ *
This class supports automatic UUID assignment for {@link EventWithSnowflake} events, and
+ * allows filtering subscribers so they only respond to events with a specific UUID. All
+ * subscription methods are chainable, and you can configure automatic unsubscription after an event
+ * has been successfully handled.
*/
public class EventFlow {
@@ -35,10 +37,7 @@ public class EventFlow {
private EventType event = null;
/** The listener returned by GlobalEventBus subscription. Used for unsubscription. */
- private Object listener;
-
- /** Flag indicating whether to automatically unsubscribe the listener after success. */
- private boolean unsubscribeAfterSuccess = false;
+ private final List listeners = new ArrayList<>();
/** Holds the results returned from the subscribed event, if any. */
private Map result = null;
@@ -46,28 +45,43 @@ public class EventFlow {
/** Empty constructor (event must be added via {@link #addPostEvent(Class, Object...)}). */
public EventFlow() {}
- /**
- * Instantiate an event of the given class and store it in this publisher.
- */
+ // New: accept an event instance directly
+ public EventFlow addPostEvent(EventType event) {
+ this.event = event;
+ return this;
+ }
+
+ // Optional: accept a Supplier to defer construction
+ public EventFlow addPostEvent(Supplier extends EventType> eventSupplier) {
+ this.event = eventSupplier.get();
+ return this;
+ }
+
+ // Keep the old class+args version if needed
public EventFlow addPostEvent(Class eventClass, Object... args) {
try {
boolean isUuidEvent = EventWithSnowflake.class.isAssignableFrom(eventClass);
- MethodHandle ctorHandle = CONSTRUCTOR_CACHE.computeIfAbsent(eventClass, cls -> {
- try {
- Class>[] paramTypes = cls.getDeclaredConstructors()[0].getParameterTypes();
- MethodType mt = MethodType.methodType(void.class, paramTypes);
- return LOOKUP.findConstructor(cls, mt);
- } catch (Exception e) {
- throw new RuntimeException("Failed to find constructor handle for " + cls, e);
- }
- });
+ MethodHandle ctorHandle =
+ CONSTRUCTOR_CACHE.computeIfAbsent(
+ eventClass,
+ cls -> {
+ try {
+ Class>[] paramTypes =
+ cls.getDeclaredConstructors()[0].getParameterTypes();
+ MethodType mt = MethodType.methodType(void.class, paramTypes);
+ return LOOKUP.findConstructor(cls, mt);
+ } catch (Exception e) {
+ throw new RuntimeException(
+ "Failed to find constructor handle for " + cls, e);
+ }
+ });
Object[] finalArgs;
int expectedParamCount = ctorHandle.type().parameterCount();
if (isUuidEvent && args.length < expectedParamCount) {
- this.eventSnowflake = new SnowflakeGenerator(1).nextId();
+ this.eventSnowflake = new SnowflakeGenerator().nextId();
finalArgs = new Object[args.length + 1];
System.arraycopy(args, 0, finalArgs, 0, args.length);
finalArgs[args.length] = this.eventSnowflake;
@@ -86,124 +100,140 @@ public class EventFlow {
}
}
- /**
- * Start listening for a response event type, chainable with perform().
- */
- public ResponseBuilder onResponse(Class eventClass) {
- return new ResponseBuilder<>(this, eventClass);
- }
+ // public EventFlow addSnowflake() {
+ // this.eventSnowflake = new SnowflakeGenerator(1).nextId();
+ // return this;
+ // }
- public static class ResponseBuilder {
- private final EventFlow parent;
- private final Class responseClass;
+ /** Subscribe by ID: only fires if UUID matches this publisher's eventId. */
+ public EventFlow onResponse(
+ Class eventClass, Consumer action, boolean unsubscribeAfterSuccess) {
+ ListenerHandler[] listenerHolder = new ListenerHandler[1];
+ listenerHolder[0] =
+ new ListenerHandler(
+ GlobalEventBus.subscribe(
+ eventClass,
+ event -> {
+ if (event.eventSnowflake() != this.eventSnowflake) return;
- ResponseBuilder(EventFlow parent, Class responseClass) {
- this.parent = parent;
- this.responseClass = responseClass;
- }
+ action.accept(event);
- /** Finalize the subscription */
- public EventFlow perform(Consumer action) {
- parent.listener = GlobalEventBus.subscribe(responseClass, event -> {
- action.accept(responseClass.cast(event));
- if (parent.unsubscribeAfterSuccess && parent.listener != null) {
- GlobalEventBus.unsubscribe(parent.listener);
- }
- });
- return parent;
- }
- }
+ if (unsubscribeAfterSuccess && listenerHolder[0] != null) {
+ GlobalEventBus.unsubscribe(listenerHolder[0]);
+ this.listeners.remove(listenerHolder[0]);
+ }
- /**
- * Subscribe by ID: only fires if UUID matches this publisher's eventId.
- */
- public EventFlow onResponse(Class eventClass, Consumer action) {
- this.listener = GlobalEventBus.subscribe(eventClass, event -> {
- if (event.eventSnowflake() == this.eventSnowflake) {
- action.accept(event);
- if (unsubscribeAfterSuccess && listener != null) {
- GlobalEventBus.unsubscribe(listener);
- }
- this.result = event.result();
- }
- });
+ this.result = event.result();
+ }));
+ this.listeners.add(listenerHolder[0]);
return this;
}
- /**
- * Subscribe by ID without explicit class.
- */
+ /** Subscribe by ID: only fires if UUID matches this publisher's eventId. */
+ public EventFlow onResponse(
+ Class eventClass, Consumer action) {
+ return this.onResponse(eventClass, action, true);
+ }
+
+ /** Subscribe by ID without explicit class. */
@SuppressWarnings("unchecked")
- public EventFlow onResponse(Consumer action) {
- this.listener = GlobalEventBus.subscribe(event -> {
- if (event instanceof EventWithSnowflake uuidEvent) {
- if (uuidEvent.eventSnowflake() == this.eventSnowflake) {
- try {
- TT typedEvent = (TT) uuidEvent;
- action.accept(typedEvent);
- if (unsubscribeAfterSuccess && listener != null) {
- GlobalEventBus.unsubscribe(listener);
- }
- this.result = typedEvent.result();
- } catch (ClassCastException ignored) {}
- }
- }
- });
+ public EventFlow onResponse(
+ Consumer action, boolean unsubscribeAfterSuccess) {
+ ListenerHandler[] listenerHolder = new ListenerHandler[1];
+ listenerHolder[0] =
+ new ListenerHandler(
+ GlobalEventBus.subscribe(
+ event -> {
+ if (!(event instanceof EventWithSnowflake uuidEvent)) return;
+ if (uuidEvent.eventSnowflake() == this.eventSnowflake) {
+ try {
+ TT typedEvent = (TT) uuidEvent;
+ action.accept(typedEvent);
+ if (unsubscribeAfterSuccess
+ && listenerHolder[0] != null) {
+ GlobalEventBus.unsubscribe(listenerHolder[0]);
+ this.listeners.remove(listenerHolder[0]);
+ }
+ this.result = typedEvent.result();
+ } catch (ClassCastException _) {
+ throw new ClassCastException(
+ "Cannot cast "
+ + event.getClass().getName()
+ + " to EventWithSnowflake");
+ }
+ }
+ }));
+ this.listeners.add(listenerHolder[0]);
return this;
}
- // choose event type
- public EventSubscriberBuilder onEvent(Class eventClass) {
- return new EventSubscriberBuilder<>(this, eventClass);
+ public EventFlow onResponse(Consumer action) {
+ return this.onResponse(action, true);
+ }
+
+ public EventFlow listen(
+ Class eventClass, Consumer action, boolean unsubscribeAfterSuccess) {
+ ListenerHandler[] listenerHolder = new ListenerHandler[1];
+ listenerHolder[0] =
+ new ListenerHandler(
+ GlobalEventBus.subscribe(
+ eventClass,
+ event -> {
+ action.accept(event);
+
+ if (unsubscribeAfterSuccess && listenerHolder[0] != null) {
+ GlobalEventBus.unsubscribe(listenerHolder[0]);
+ this.listeners.remove(listenerHolder[0]);
+ }
+ }));
+ this.listeners.add(listenerHolder[0]);
+ return this;
}
- // One-liner shorthand
public EventFlow listen(Class eventClass, Consumer action) {
- return this.onEvent(eventClass).perform(action);
+ return this.listen(eventClass, action, true);
}
- // Builder for chaining .onEvent(...).perform(...)
- public static class EventSubscriberBuilder {
- private final EventFlow publisher;
- private final Class eventClass;
+ @SuppressWarnings("unchecked")
+ public EventFlow listen(
+ Consumer action, boolean unsubscribeAfterSuccess) {
+ ListenerHandler[] listenerHolder = new ListenerHandler[1];
+ listenerHolder[0] =
+ new ListenerHandler(
+ GlobalEventBus.subscribe(
+ event -> {
+ if (!(event instanceof EventType nonUuidEvent)) return;
+ try {
+ TT typedEvent = (TT) nonUuidEvent;
+ action.accept(typedEvent);
+ if (unsubscribeAfterSuccess && listenerHolder[0] != null) {
+ GlobalEventBus.unsubscribe(listenerHolder[0]);
+ this.listeners.remove(listenerHolder[0]);
+ }
+ } catch (ClassCastException _) {
+ throw new ClassCastException(
+ "Cannot cast "
+ + event.getClass().getName()
+ + " to EventWithSnowflake");
+ }
+ }));
+ this.listeners.add(listenerHolder[0]);
+ return this;
+ }
- EventSubscriberBuilder(EventFlow publisher, Class eventClass) {
- this.publisher = publisher;
- this.eventClass = eventClass;
- }
-
- public EventFlow perform(Consumer action) {
- publisher.listener = GlobalEventBus.subscribe(eventClass, event -> {
- action.accept(eventClass.cast(event));
- if (publisher.unsubscribeAfterSuccess && publisher.listener != null) {
- GlobalEventBus.unsubscribe(publisher.listener);
- }
- });
- return publisher;
- }
+ public EventFlow listen(Consumer action) {
+ return this.listen(action, true);
}
/** Post synchronously */
public EventFlow postEvent() {
- GlobalEventBus.post(event);
+ GlobalEventBus.post(this.event);
return this;
}
/** Post asynchronously */
public EventFlow asyncPostEvent() {
- GlobalEventBus.postAsync(event);
- return this;
- }
-
- public EventFlow unsubscribeAfterSuccess() {
- this.unsubscribeAfterSuccess = true;
- return this;
- }
-
- public EventFlow unsubscribeNow() {
- if (unsubscribeAfterSuccess && listener != null) {
- GlobalEventBus.unsubscribe(listener);
- }
+ GlobalEventBus.postAsync(this.event);
return this;
}
@@ -215,7 +245,11 @@ public class EventFlow {
return event;
}
- public long getEventId() {
+ public ListenerHandler[] getListeners() {
+ return listeners.toArray(new ListenerHandler[0]);
+ }
+
+ public long getEventSnowflake() {
return eventSnowflake;
}
}
diff --git a/framework/src/main/java/org/toop/framework/eventbus/GlobalEventBus.java b/framework/src/main/java/org/toop/framework/eventbus/GlobalEventBus.java
index 44a84f4..41386bf 100644
--- a/framework/src/main/java/org/toop/framework/eventbus/GlobalEventBus.java
+++ b/framework/src/main/java/org/toop/framework/eventbus/GlobalEventBus.java
@@ -3,26 +3,26 @@ package org.toop.framework.eventbus;
import com.lmax.disruptor.*;
import com.lmax.disruptor.dsl.Disruptor;
import com.lmax.disruptor.dsl.ProducerType;
-import org.toop.framework.eventbus.events.EventType;
-import org.toop.framework.eventbus.events.EventWithSnowflake;
-
import java.util.Map;
import java.util.concurrent.*;
import java.util.function.Consumer;
+import org.toop.framework.eventbus.events.EventType;
+import org.toop.framework.eventbus.events.EventWithSnowflake;
/**
- * GlobalEventBus backed by the LMAX Disruptor for ultra-low latency,
- * high-throughput event publishing.
+ * GlobalEventBus backed by the LMAX Disruptor for ultra-low latency, high-throughput event
+ * publishing.
*/
public final class GlobalEventBus {
/** Map of event class to type-specific listeners. */
- private static final Map, CopyOnWriteArrayList>> LISTENERS =
- new ConcurrentHashMap<>();
+ private static final Map, CopyOnWriteArrayList>>
+ LISTENERS = new ConcurrentHashMap<>();
/** Map of event class to Snowflake-ID-specific listeners. */
- private static final Map, ConcurrentHashMap>> UUID_LISTENERS =
- new ConcurrentHashMap<>();
+ private static final Map<
+ Class>, ConcurrentHashMap>>
+ UUID_LISTENERS = new ConcurrentHashMap<>();
/** Disruptor ring buffer size (must be power of two). */
private static final int RING_BUFFER_SIZE = 1024 * 64;
@@ -34,27 +34,29 @@ public final class GlobalEventBus {
private static final RingBuffer RING_BUFFER;
static {
- ThreadFactory threadFactory = r -> {
- Thread t = new Thread(r, "EventBus-Disruptor");
- t.setDaemon(true);
- return t;
- };
+ ThreadFactory threadFactory =
+ r -> {
+ Thread t = new Thread(r, "EventBus-Disruptor");
+ t.setDaemon(true);
+ return t;
+ };
- DISRUPTOR = new Disruptor<>(
- EventHolder::new,
- RING_BUFFER_SIZE,
- threadFactory,
- ProducerType.MULTI,
- new BusySpinWaitStrategy()
- );
+ DISRUPTOR =
+ new Disruptor<>(
+ EventHolder::new,
+ RING_BUFFER_SIZE,
+ threadFactory,
+ ProducerType.MULTI,
+ new BusySpinWaitStrategy());
// Single consumer that dispatches to subscribers
- DISRUPTOR.handleEventsWith((holder, seq, endOfBatch) -> {
- if (holder.event != null) {
- dispatchEvent(holder.event);
- holder.event = null;
- }
- });
+ DISRUPTOR.handleEventsWith(
+ (holder, seq, endOfBatch) -> {
+ if (holder.event != null) {
+ dispatchEvent(holder.event);
+ holder.event = null;
+ }
+ });
DISRUPTOR.start();
RING_BUFFER = DISRUPTOR.getRingBuffer();
@@ -71,17 +73,21 @@ public final class GlobalEventBus {
// ------------------------------------------------------------------------
// Subscription
// ------------------------------------------------------------------------
- public static Consumer subscribe(Class eventClass, Consumer listener) {
+ public static Consumer super EventType> subscribe(
+ Class eventClass, Consumer listener) {
+
CopyOnWriteArrayList> list =
LISTENERS.computeIfAbsent(eventClass, k -> new CopyOnWriteArrayList<>());
- list.add(event -> listener.accept(eventClass.cast(event)));
- return listener;
+
+ Consumer super EventType> wrapper = event -> listener.accept(eventClass.cast(event));
+ list.add(wrapper);
+ return wrapper;
}
- public static Consumer