diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2fe6d90..b436430 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,12 +8,14 @@ env: jobs: build: runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: '16' + java-version: '24' cache: gradle - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b @@ -22,12 +24,14 @@ jobs: - run: gradle assemble --no-daemon test: runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: '16' + java-version: '24' cache: gradle - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b @@ -43,7 +47,7 @@ jobs: - uses: actions/setup-java@master with: distribution: 'temurin' - java-version: '16' + java-version: '24' cache: gradle - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 81a2419..36cf74d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,10 +13,10 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: - java-version: '16' + java-version: '24' distribution: 'temurin' - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b diff --git a/build.gradle b/build.gradle index 5249e1c..c9814ba 100644 --- a/build.gradle +++ b/build.gradle @@ -13,11 +13,11 @@ repositories { } compileJava { - options.release = 19 + options.release = 24 } jacoco { - toolVersion = "0.8.8" + toolVersion = "0.8.13" } jacocoTestReport { @@ -28,7 +28,7 @@ jacocoTestReport { java { toolchain { - languageVersion = JavaLanguageVersion.of(19) + languageVersion = JavaLanguageVersion.of(24) } withSourcesJar() withJavadocJar() diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ffed3a2..8049c68 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/suga/engine/GameEngine.java b/src/main/java/suga/engine/GameEngine.java index 9fdbccd..ccb2d3a 100644 --- a/src/main/java/suga/engine/GameEngine.java +++ b/src/main/java/suga/engine/GameEngine.java @@ -1,14 +1,14 @@ package suga.engine; +import suga.engine.game.Game; import suga.engine.graphics.GraphicsPanel; +import suga.engine.input.keyboard.GameKeyListener; import suga.engine.input.mouse.GameMouseListener; import suga.engine.logger.GeneralLogger; import suga.engine.logger.Logger; import suga.engine.threads.GameLogicThread; import suga.engine.threads.GraphicsThread; import suga.engine.threads.SugaThread; -import suga.engine.input.keyboard.GameKeyListener; -import suga.engine.game.Game; import javax.swing.*; import java.awt.*; diff --git a/src/main/java/suga/engine/game/BasicGame.java b/src/main/java/suga/engine/game/BasicGame.java index ac08fce..300c656 100644 --- a/src/main/java/suga/engine/game/BasicGame.java +++ b/src/main/java/suga/engine/game/BasicGame.java @@ -5,6 +5,8 @@ import suga.engine.game.objects.GameObject; import suga.engine.graphics.DrawListener; import suga.engine.graphics.GraphicsPanel; +import suga.engine.input.keyboard.GameKeyListener; +import suga.engine.input.keyboard.KeyValue; import suga.engine.input.mouse.BasicMouseListener; import suga.engine.input.mouse.GameMouseListener; import suga.engine.logger.Level; @@ -12,8 +14,6 @@ import suga.engine.physics.PhysicsEngine; import suga.engine.physics.collidables.Collidable; import suga.engine.threads.SugaThread; -import suga.engine.input.keyboard.GameKeyListener; -import suga.engine.input.keyboard.KeyValue; import java.awt.event.MouseEvent; import java.util.*; diff --git a/src/main/java/suga/engine/game/BasicScene.java b/src/main/java/suga/engine/game/BasicScene.java index 2b690ee..623dc91 100644 --- a/src/main/java/suga/engine/game/BasicScene.java +++ b/src/main/java/suga/engine/game/BasicScene.java @@ -1,6 +1,7 @@ package suga.engine.game; import suga.engine.input.keyboard.KeyValue; + import java.awt.*; /** diff --git a/src/main/java/suga/engine/game/Game.java b/src/main/java/suga/engine/game/Game.java index 889e5c9..c6a7b23 100644 --- a/src/main/java/suga/engine/game/Game.java +++ b/src/main/java/suga/engine/game/Game.java @@ -4,9 +4,9 @@ import suga.engine.game.objects.GameObject; import suga.engine.graphics.DrawListener; import suga.engine.graphics.GraphicsPanel; +import suga.engine.input.keyboard.GameKeyListener; import suga.engine.input.mouse.GameMouseListener; import suga.engine.threads.SugaThread; -import suga.engine.input.keyboard.GameKeyListener; /** * Games require a main game loop to run along with game components that need to be run every game cycle. diff --git a/src/main/java/suga/engine/game/objects/BasicGameObject.java b/src/main/java/suga/engine/game/objects/BasicGameObject.java index db0043b..f4d0897 100644 --- a/src/main/java/suga/engine/game/objects/BasicGameObject.java +++ b/src/main/java/suga/engine/game/objects/BasicGameObject.java @@ -1,7 +1,7 @@ package suga.engine.game.objects; -import suga.engine.graphics.GraphicsPanel; import suga.engine.graphics.DrawListener; +import suga.engine.graphics.GraphicsPanel; import suga.engine.physics.BasicPhysical; import suga.engine.physics.Vector; import suga.engine.physics.collidables.Collidable; diff --git a/src/main/java/suga/engine/graphics/GraphicsPanelInterface.java b/src/main/java/suga/engine/graphics/GraphicsPanelInterface.java index c70f59c..405cf09 100644 --- a/src/main/java/suga/engine/graphics/GraphicsPanelInterface.java +++ b/src/main/java/suga/engine/graphics/GraphicsPanelInterface.java @@ -99,4 +99,10 @@ public interface GraphicsPanelInterface { * @param image The image to draw to the screen. */ void addImage (int x, int y, int width, int height, BufferedImage image); + + /** + * Called each frame by the GraphicsThread. This method should be inherited from JComponent but is defined in + * Component. + */ + void repaint (); } diff --git a/src/main/java/suga/engine/sound/JavaxSoundManager.java b/src/main/java/suga/engine/sound/JavaxSoundManager.java index 85fc750..20cffba 100644 --- a/src/main/java/suga/engine/sound/JavaxSoundManager.java +++ b/src/main/java/suga/engine/sound/JavaxSoundManager.java @@ -4,7 +4,10 @@ import suga.engine.logger.Level; import javax.sound.sampled.*; -import java.io.*; +import java.io.BufferedInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; import java.util.HashMap; import java.util.Map; import java.util.Random; diff --git a/src/main/java/suga/engine/threads/AbstractThread.java b/src/main/java/suga/engine/threads/AbstractThread.java new file mode 100644 index 0000000..84136d1 --- /dev/null +++ b/src/main/java/suga/engine/threads/AbstractThread.java @@ -0,0 +1,115 @@ +package suga.engine.threads; + +import suga.engine.GameEngine; +import suga.engine.logger.Level; + +/** + * The AbstractThread implements most of the common elements between threads including setters/getters for pausing, + * frame rate and more. + * + * @author Sugaku + */ +public abstract class AbstractThread extends Thread implements SugaThread { + + /** + * Whether to exit the thread. + */ + protected boolean stopped = false; + + /** + * Whether to simulate game logic or not. + */ + protected boolean paused = false; + + /** + * The target frame rate for this thread. + */ + protected int frameRate; + + /** + * The time that this graphics thread was started. Used in calculating average frame rate. + */ + protected long startTime = 0; + + /** + * The number of frames that have been rendered since the thread started. + */ + protected long frames = 0; + + + /** + * Creates a new thread with the given target rate. + * + * @param frameRate The target frequency to draw frames at. + */ + protected AbstractThread (int frameRate) { + this.frameRate = frameRate; + } + + /** + * Sets the target frame rate of this GraphicsThread. + * + * @param val The new value for the target frame rate. + */ + public void setFrameRate (int val) { + if (val <= 0) { + GameEngine.getInstance().getLogger().log(this.getClass().toString().replaceAll(".+\\.", "") + ": " + val + ". Only natural numbers (no zero) allowed.", Level.EXCEPTION); + return; + } + this.frameRate = val; + } + + /** + * Accessor method for the current target frame rate of the thread. + * + * @return The current target thread refresh rate. + */ + public int getFrameRate () { + return frameRate; + } + + /** + * Sets whether the thread is paused or not. + * + * @param val Whether the thread should be paused or not. + */ + public void setPaused (boolean val) { + paused = val; + } + + /** + * Accessor method for the current status of the thread. + * + * @return Whether the thread is paused currently or not. + */ + public boolean getPaused () { + return paused; + } + + /** + * Sets whether the thread is stopped or not. + * + * @param val Whether the thread should be stopped. + */ + public void setStopped (boolean val) { + stopped = val; + } + + /** + * Accessor method for the current status of the thread. + * + * @return Whether this thread has been stopped or not. + */ + public boolean getStopped () { + return stopped; + } + + /** + * Returns the average frame rate while this GraphicsThread has been running. + * + * @return The average frame rate of this thread since starting. + */ + public double getFPS () { + return (frames * 1.0) / ((System.currentTimeMillis() - startTime) / 1000.0); + } +} diff --git a/src/main/java/suga/engine/threads/GameLogicThread.java b/src/main/java/suga/engine/threads/GameLogicThread.java index fa8a5dd..47a844c 100644 --- a/src/main/java/suga/engine/threads/GameLogicThread.java +++ b/src/main/java/suga/engine/threads/GameLogicThread.java @@ -8,28 +8,13 @@ * * @author Sugaku */ -public class GameLogicThread extends Thread implements SugaThread { - - /** - * Whether to exit the thread. - */ - protected boolean stopped = false; - - /** - * Whether to simulate game logic or not. - */ - protected boolean paused = false; +public class GameLogicThread extends AbstractThread implements SugaThread { /** * The game that should be called once every 1/60th of a second. */ private final Game game; - /** - * A constant on how fast game logic should be called. - */ - private final int LOGIC_RATE; - /** * Creates a new GameLogicThread. * @@ -37,72 +22,39 @@ public class GameLogicThread extends Thread implements SugaThread { * @param rate How many times the logic should be run per second as a maximum. */ public GameLogicThread (Game game, int rate) { + super(rate); this.game = game; - LOGIC_RATE = rate; game.setThread(this); } - /** - * Sets whether the thread is paused or not. - * - * @param val Whether the thread should be paused or not. - */ - public void setPaused (boolean val) { - paused = val; - } - - /** - * Accessor method for the current status of the thread. - * - * @return Whether the thread is paused currently or not. - */ - public boolean getPaused () { - return paused; - } - - /** - * Sets whether the thread is stopped or not. - * - * @param val Whether the thread should be stopped. - */ - public void setStopped (boolean val) { - stopped = val; - } - - /** - * Accessor method for the current status of the thread. - * - * @return Whether this thread has been stopped or not. - */ - public boolean getStopped () { - return stopped; - } - /** * Called to run the Game logic thread. */ @Override public void run () { - long lastFinished = 0; + startTime = System.currentTimeMillis(); + frames = 0; while (!stopped) { - long logicTime = System.currentTimeMillis() - lastFinished; - if (logicTime < (1000 / LOGIC_RATE)) { - try { - //noinspection BusyWait - sleep((1000 / LOGIC_RATE) - logicTime); - } catch (Exception e) { - GameEngine.getInstance().getLogger().log(e); - } - } - lastFinished = System.currentTimeMillis(); + long runtime = 0; game.processInput(); if (!paused) { + long frameStart = System.currentTimeMillis(); try { game.loop(); } catch (Exception e) { GameEngine.getInstance().getLogger().log(e); } + runtime = System.currentTimeMillis() - frameStart; + } + try { + long toWait = Math.round(((1000.0 - ((System.currentTimeMillis() - startTime) % 1000)) / (frameRate - (frames % frameRate) )) - runtime); + if (toWait < 0) toWait = 0; + //noinspection BusyWait + sleep(toWait); + } catch (InterruptedException e) { + GameEngine.getInstance().getLogger().log(e); } + frames++; } } } diff --git a/src/main/java/suga/engine/threads/GraphicsThread.java b/src/main/java/suga/engine/threads/GraphicsThread.java index 9392164..fc62a15 100644 --- a/src/main/java/suga/engine/threads/GraphicsThread.java +++ b/src/main/java/suga/engine/threads/GraphicsThread.java @@ -1,44 +1,19 @@ package suga.engine.threads; import suga.engine.GameEngine; -import suga.engine.graphics.GraphicsPanel; +import suga.engine.graphics.GraphicsPanelInterface; /** * A thread used to refresh the graphics of a panel as fast as possible. * * @author Sugaku */ -public class GraphicsThread extends Thread implements SugaThread { - - /** - * Whether to exit the thread. - */ - protected boolean stopped = false; - - /** - * Whether to simulate game logic or not. - */ - protected boolean paused = false; +public class GraphicsThread extends AbstractThread implements SugaThread { /** * The panel that should be redrawn every frame. */ - private final GraphicsPanel panel; - - /** - * The time that this graphics thread was started. Used in calculating average frame rate. - */ - private static long startTime = 0; - - /** - * The number of frames that have been rendered since the thread started. - */ - private static long frames = 0; - - /** - * The target frame rate for this GraphicsThread. - */ - private final int FRAME_RATE; + private final GraphicsPanelInterface panel; /** * Creates a new graphics thread with the given panel. @@ -46,83 +21,40 @@ public class GraphicsThread extends Thread implements SugaThread { * @param panel The panel to refresh for every frame. * @param frameRate The target frequency to draw frames at. */ - public GraphicsThread (GraphicsPanel panel, int frameRate) { + public GraphicsThread (GraphicsPanelInterface panel, int frameRate) { + super(frameRate); this.panel = panel; - FRAME_RATE = frameRate; + this.frameRate = frameRate; panel.setThread(this); } - /** - * Sets whether the thread is paused or not. - * - * @param val Whether the thread should be paused or not. - */ - public void setPaused (boolean val) { - paused = val; - } - - /** - * Accessor method for the current status of the thread. - * - * @return Whether the thread is paused currently or not. - */ - public boolean getPaused () { - return paused; - } - - /** - * Sets whether the thread is stopped or not. - * - * @param val Whether the thread should be stopped. - */ - public void setStopped (boolean val) { - stopped = val; - } - - /** - * Accessor method for the current status of the thread. - * - * @return Whether this thread has been stopped or not. - */ - public boolean getStopped () { - return stopped; - } - /** * Called to run the Graphics thread. */ @Override public void run () { startTime = System.currentTimeMillis(); - long lastFinished = 0; + frames = 0; while (!stopped) { - long drawTime = System.currentTimeMillis() - lastFinished; - if (drawTime < (1000 / FRAME_RATE)) { - try { - //noinspection BusyWait - sleep((int) ((1000 / FRAME_RATE) - drawTime)); - } catch (Exception e) { - GameEngine.getInstance().getLogger().log(e); - } - } - lastFinished = System.currentTimeMillis(); + long runtime = 0; if (!paused) { + long frameStart = System.currentTimeMillis(); try { panel.repaint(); } catch (Exception e) { GameEngine.getInstance().getLogger().log(e); } + runtime = System.currentTimeMillis() - frameStart; + } + try { + long toWait = Math.round(((1000.0 - ((System.currentTimeMillis() - startTime) % 1000)) / (frameRate - (frames % frameRate) )) - runtime); + if (toWait < 0) toWait = 0; + //noinspection BusyWait + sleep(toWait); + } catch (InterruptedException e) { + GameEngine.getInstance().getLogger().log(e); } frames++; } } - - /** - * Returns the average frame rate while this GraphicsThread has been running. - * - * @return The average frame rate of this thread since starting. - */ - public static double getFPS () { - return (frames * 1.0) / ((System.currentTimeMillis() - startTime) / 1000.0); - } } diff --git a/src/test/java/suga/engine/physics/hitboxes/SquareHitBoxTest.java b/src/test/java/suga/engine/physics/hitboxes/SquareHitBoxTest.java index 0a3c047..16a2b6e 100644 --- a/src/test/java/suga/engine/physics/hitboxes/SquareHitBoxTest.java +++ b/src/test/java/suga/engine/physics/hitboxes/SquareHitBoxTest.java @@ -59,7 +59,7 @@ void isInside () { * Checks if the given point is along the boundary of the hit box or not. Should return false for interior points. */ @ParameterizedTest - @CsvFileSource(resources = "/suga/engine/physics/hitboxes/SquareHitBox/touching.csv", numLinesToSkip = 2, delimiter = ',') + @CsvFileSource(resources = "/suga/engine/physics/hitboxes/SquareHitBox/touching.csv", numLinesToSkip = 1, delimiter = ',') void touching (int width, int height, int cx, int cy, int cz, int x, int y, int z, boolean expected, String reason) { HitBox hitBox = new SquareHitBox(width, height, new Vector(cx, cy, cz)); Vector testPoint = new Vector(x, y, z); diff --git a/src/test/java/suga/engine/threads/GameLogicThreadTest.java b/src/test/java/suga/engine/threads/GameLogicThreadTest.java new file mode 100644 index 0000000..d4ea80e --- /dev/null +++ b/src/test/java/suga/engine/threads/GameLogicThreadTest.java @@ -0,0 +1,59 @@ +package suga.engine.threads; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvFileSource; +import org.mockito.Mockito; +import suga.engine.GameEngine; +import suga.engine.game.Game; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests written for the Game thread. + * + * @author Sugaku + */ +class GameLogicThreadTest { + + /** + * The thread currently under testing. + */ + private GameLogicThread thread; + + /** + * A mock Game without any logic. + */ + private Game game = Mockito.mock(Game.class); + + /** + * Reset the mocks used in the GraphicsThread test. + */ + @BeforeEach + void reset () { + game = Mockito.mock(Game.class); + thread = new GameLogicThread(game, 60); + } + + /** + * Tests whether running the game logic reaches the intended frame rate. + */ // todo perhaps refactor into a k-tail test. + @ParameterizedTest + @CsvFileSource(resources = "/suga/engine/threads/run.csv", numLinesToSkip = 1, delimiter = ',') + void run_PerformsCloseToRequest (int targetFps, long sampleTime) { + final double error = 0.01; // We allow a 1% deviation from the target frame rate. + thread = new GameLogicThread(game, targetFps); + thread.start(); + try { + Thread.sleep(sampleTime * 1000); + } catch (InterruptedException exception) { + fail("Failed to wait for given duration."); + } + thread.setStopped(true); + GameEngine.getInstance().getLogger().log(String.format("Game Thread Test: Wanted %dfps and got %.2ffps. Error is %.2f %%", targetFps, thread.getFPS(), (100 - ((targetFps * 100.0) / thread.getFPS())))); +// assertEquals(targetFps, GraphicsThread.getFPS(), targetFps * error, "Graphics thread should run within " + error + "% of target fps."); + // Results partially depend on test duration and device running them. My testing resulted in a margin of less + // than 1% most often. Regardless no need to fail builds based on the results of this test. + assertTrue(true); + } +} \ No newline at end of file diff --git a/src/test/java/suga/engine/threads/GraphicsThreadTest.java b/src/test/java/suga/engine/threads/GraphicsThreadTest.java new file mode 100644 index 0000000..63b311a --- /dev/null +++ b/src/test/java/suga/engine/threads/GraphicsThreadTest.java @@ -0,0 +1,59 @@ +package suga.engine.threads; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvFileSource; +import org.mockito.Mockito; +import suga.engine.GameEngine; +import suga.engine.graphics.GraphicsPanelInterface; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests written for the GraphicsThread. + * + * @author Sugaku + */ +class GraphicsThreadTest { + + /** + * The thread currently under testing. + */ + private GraphicsThread thread; + + /** + * A mock GraphicsPanelInterface without any logic in .repaint(); + */ + private GraphicsPanelInterface graphicsPanelInterface = Mockito.mock(GraphicsPanelInterface.class); + + /** + * Reset the mocks used in the GraphicsThread test. + */ + @BeforeEach + void reset () { + graphicsPanelInterface = Mockito.mock(GraphicsPanelInterface.class); + thread = new GraphicsThread(graphicsPanelInterface, 60); + } + + /** + * Tests whether running the GraphicsThread reaches the intended frame rate. + */ // todo perhaps refactor into a k-tail test. + @ParameterizedTest + @CsvFileSource(resources = "/suga/engine/threads/run.csv", numLinesToSkip = 1, delimiter = ',') + void run_PerformsCloseToRequest (int targetFps, long sampleTime) { + final double error = 0.01; // We allow a 1% deviation from the target frame rate. + thread = new GraphicsThread(graphicsPanelInterface, targetFps); + thread.start(); + try { + Thread.sleep(sampleTime * 1000); + } catch (InterruptedException exception) { + fail("Failed to wait for given duration."); + } + thread.setStopped(true); + GameEngine.getInstance().getLogger().log(String.format("Graphics Thread Test: Wanted %dfps and got %.2ffps. Error is %.2f %%", targetFps, thread.getFPS(), (100 - ((targetFps * 100.0) / thread.getFPS())))); +// assertEquals(targetFps, GraphicsThread.getFPS(), targetFps * error, "Graphics thread should run within " + error + "% of target fps."); + // Results partially depend on test duration and device running them. My testing resulted in a margin of less + // than 1% most often. Regardless no need to fail builds based on the results of this test. + assertTrue(true); + } +} diff --git a/src/test/resources/suga/engine/threads/run.csv b/src/test/resources/suga/engine/threads/run.csv new file mode 100644 index 0000000..ac83f6c --- /dev/null +++ b/src/test/resources/suga/engine/threads/run.csv @@ -0,0 +1,9 @@ +Target Framerate, Sample Time in Seconds + +60, 10 +120, 10 +144, 10 +30, 10 +50, 10 +25, 10 +13, 10