diff --git a/fxgl-entity/src/main/java/com/almasb/fxgl/entity/component/TimerActionComponent.java b/fxgl-entity/src/main/java/com/almasb/fxgl/entity/component/TimerActionComponent.java new file mode 100644 index 0000000000..a3778dfaf0 --- /dev/null +++ b/fxgl-entity/src/main/java/com/almasb/fxgl/entity/component/TimerActionComponent.java @@ -0,0 +1,87 @@ +/* + * FXGL - JavaFX Game Library. The MIT License (MIT). + * Copyright (c) AlmasB (almaslvl@gmail.com). + * See LICENSE for details. + */ + +package com.almasb.fxgl.entity.component; + +import com.almasb.fxgl.time.Timer; +import com.almasb.fxgl.time.TimerAction; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.util.Duration; + +/** + * Component to schedule the execution of actions. + * The timer's time per frame (TPF) is tied to the entity's TPF. + * The entity's TPF can be modified by {@link com.almasb.fxgl.entity.components.TimeComponent}. + * + * @implNote - A wrapper around a Timer class, as a component. + * + * @author Michael Pearson (https://github.com/michqql/) + */ +public final class TimerActionComponent extends Component { + + private final Timer timer = new Timer(); + + @Override + public void onUpdate(double tpf) { + this.timer.update(tpf); + } + + /** + * The Runnable [action] will be scheduled to start at given [interval]. + * The action will start for the first time after given interval. + * The action will be scheduled unlimited number of times unless user cancels it + * via the returned action object. + * + * @return timer action + */ + public TimerAction runAtInterval(Runnable action, Duration interval) { + return timer.runAtInterval(action, interval); + } + + /** + * The Runnable [action] will be scheduled to start at given [interval]. + * The action will start for the first time after given interval. + * The action will be scheduled [limit] number of times unless user cancels it + * via the returned action object. + * + * @return timer action + */ + public TimerAction runAtInterval(Runnable action, Duration interval, int limit) { + return timer.runAtInterval(action, interval, limit); + } + + /** + * The Runnable [action] will be scheduled to start at given [interval]. + * The Runnable action will be scheduled IFF + * [whileCondition] is initially true. + * The action will start for the first time after given interval. + * The action will be removed from schedule when [whileCondition] becomes "false". + * Note: you must retain the reference to the [whileCondition] property to avoid it being + * garbage collected, otherwise the [action] may never stop. + * + * @return timer action + */ + public TimerAction runAtIntervalWhile(Runnable action, Duration interval, ReadOnlyBooleanProperty whileCondition) { + return timer.runAtIntervalWhile(action, interval, whileCondition); + } + + /** + * The Runnable [action] will be scheduled to run once after given [delay]. + * The action can be cancelled before it starts via the returned action object. + * + * @return timer action + */ + public TimerAction runOnceAfter(Runnable action, Duration delay) { + return timer.runOnceAfter(action, delay); + } + + /** + * Remove all scheduled actions. + */ + public void clear() { + timer.clear(); + } +} diff --git a/fxgl-entity/src/test/java/com/almasb/fxgl/entity/component/TimerActionComponentTest.java b/fxgl-entity/src/test/java/com/almasb/fxgl/entity/component/TimerActionComponentTest.java new file mode 100644 index 0000000000..94a180de0d --- /dev/null +++ b/fxgl-entity/src/test/java/com/almasb/fxgl/entity/component/TimerActionComponentTest.java @@ -0,0 +1,267 @@ +/* + * FXGL - JavaFX Game Library. The MIT License (MIT). + * Copyright (c) AlmasB (almaslvl@gmail.com). + * See LICENSE for details. + */ + +package com.almasb.fxgl.entity.component; + +import com.almasb.fxgl.time.TimerAction; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.*; +import javafx.util.Duration; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the {@link TimerActionComponent} class. + * + * @author Michael Pearson (https://github.com/michqql/) + */ +public class TimerActionComponentTest { + + /** + * Tests that the action is ran multiple times as the interval elapses. + * Tests the method {@link TimerActionComponent#runAtInterval(Runnable, Duration, int) runAtInterval}. + */ + @Test + public void testRunAtInterval() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + timerActionComponent.runAtInterval(action, Duration.seconds(0.5f)); + + assertEquals(0, executionCounter.get()); + timerActionComponent.onUpdate(0.5f); + assertEquals(1, executionCounter.get()); + timerActionComponent.onUpdate(0.4f); + assertEquals(1, executionCounter.get()); + timerActionComponent.onUpdate(0.2f); + assertEquals(2, executionCounter.get()); + } + + /** + * Tests that the repeating action will not be executed after being cancelled. + */ + @Test + public void testRunAtIntervalThenCancel() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + TimerAction timerAction = timerActionComponent.runAtInterval(action, Duration.seconds(0.5f)); + + assertEquals(0, executionCounter.get()); + timerActionComponent.onUpdate(0.5f); + assertEquals(1, executionCounter.get()); + + timerAction.expire(); + + timerActionComponent.onUpdate(0.5f); + assertEquals(1, executionCounter.get()); + } + + /** + * Tests that the action can be executed multiple times. + */ + @Test + public void testRunAtIntervalInLoop() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + timerActionComponent.runAtInterval(action, Duration.seconds(0.5f)); + + for(int i = 0; i < 10; ++i) { + timerActionComponent.onUpdate(0.5f); + assertEquals(i + 1, executionCounter.get()); + } + } + + /** + * Tests that the action expires with the limit and will not execute again. + */ + @Test + public void testRunAtIntervalWithLimit() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + timerActionComponent.runAtInterval(action, Duration.seconds(0.5f), 4); + + for(int i = 0; i < 10; ++i) { + timerActionComponent.onUpdate(0.5f); + } + + assertEquals(4, executionCounter.get()); + } + + /** + * Tests that the action expires when the condition becomes false. + */ + @Test + public void testRunAtIntervalConditional() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + IntegerProperty iterationCount = new SimpleIntegerProperty(); + BooleanBinding condition = iterationCount.lessThan(5); + + BooleanProperty conditionProperty = new SimpleBooleanProperty(); + conditionProperty.bind(condition); + + timerActionComponent.runAtIntervalWhile(action, Duration.seconds(0.25f), conditionProperty); + + for(int i = 0; i < 10; ++i) { + timerActionComponent.onUpdate(0.5f); + iterationCount.set(iterationCount.get() + 1); + } + + assertEquals(5, executionCounter.get()); + } + + /** + * Tests that the conditional action can be cancelled by the user. + */ + @Test + public void testRunAtIntervalConditionalCancelledEarly() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + IntegerProperty iterationCount = new SimpleIntegerProperty(); + BooleanBinding condition = iterationCount.lessThan(5); + + BooleanProperty conditionProperty = new SimpleBooleanProperty(); + conditionProperty.bind(condition); + + TimerAction timerAction = timerActionComponent.runAtIntervalWhile(action, Duration.seconds(0.25f), conditionProperty); + + for(int i = 0; i < 10; ++i) { + timerActionComponent.onUpdate(0.5f); + iterationCount.set(iterationCount.get() + 1); + + /* Cancel on the 3rd iteration */ + if(i == 2) + timerAction.expire(); + } + + assertEquals(3, executionCounter.get()); + } + + /** + * Tests that the action is ran exactly once after the delay has elapsed + * when using {@link TimerActionComponent#runOnceAfter(Runnable, Duration) runOnceAfter}. + */ + @Test + public void testRunOnceAfter() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + timerActionComponent.runOnceAfter(action, Duration.seconds(1)); + + assertEquals(0, executionCounter.get()); + timerActionComponent.onUpdate(1.0f); + assertEquals(1, executionCounter.get()); + timerActionComponent.onUpdate(1.0f); + assertEquals(1, executionCounter.get()); + } + + /** + * Tests that the action is only ran after the delay has elapsed, + * when the component has already seen time elapse. + * Tests the method {@link TimerActionComponent#runOnceAfter(Runnable, Duration) runOnceAfter}. + */ + @Test + public void testRunOnceAfterWithStartingTime() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + timerActionComponent.onUpdate(2.0f); + timerActionComponent.runOnceAfter(action, Duration.seconds(1)); + + assertEquals(0, executionCounter.get()); + timerActionComponent.onUpdate(1.0f); + assertEquals(1, executionCounter.get()); + } + + /** + * Tests that the action is not ran if not enough time elapses. + * Tests the method {@link TimerActionComponent#runOnceAfter(Runnable, Duration) runOnceAfter}. + */ + @Test + public void testRunOnceNotElapsed() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + timerActionComponent.runOnceAfter(action, Duration.seconds(1)); + + assertEquals(0, executionCounter.get()); + timerActionComponent.onUpdate(0.999f); + assertEquals(0, executionCounter.get()); + } + + /** + * Tests that the action is not executed if cancelled before the delay elapses. + * Tests the method {@link TimerActionComponent#runOnceAfter(Runnable, Duration) runOnceAfter}. + */ + @Test + public void testRunOnceCancelled() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + TimerAction timerAction = timerActionComponent.runOnceAfter(action, Duration.seconds(1)); + timerAction.expire(); + + assertEquals(0, executionCounter.get()); + timerActionComponent.onUpdate(1.0f); + assertEquals(0, executionCounter.get()); + } + + /** + * Tests that multiple actions can be scheduled. + */ + @Test + public void testRunOnceWithMultiple() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + timerActionComponent.runOnceAfter(action, Duration.seconds(0.1f)); + timerActionComponent.runOnceAfter(action, Duration.seconds(0.2f)); + timerActionComponent.runOnceAfter(action, Duration.seconds(0.29f)); /* Floating point cannot represent 0.3 well */ + timerActionComponent.runOnceAfter(action, Duration.seconds(0.4f)); + timerActionComponent.runOnceAfter(action, Duration.seconds(0.5f)); + + + for(int i = 0; i < 5; ++i) { + timerActionComponent.onUpdate(0.1f); + assertEquals(i + 1, executionCounter.get()); + } + } + + /** + * Tests that clearing the component will remove all actions. + */ + @Test + public void testClear() { + final TimerActionComponent timerActionComponent = new TimerActionComponent(); + final IntegerProperty executionCounter = new SimpleIntegerProperty(); + final Runnable action = () -> executionCounter.set(executionCounter.get() + 1); + + timerActionComponent.runAtInterval(action, Duration.seconds(1)); + timerActionComponent.runOnceAfter(action, Duration.seconds(0.1f)); + + timerActionComponent.clear(); + timerActionComponent.onUpdate(1.0f); + assertEquals(0, executionCounter.get()); + } + +} diff --git a/fxgl-samples/src/main/java/intermediate/components/PongVsComputerApp.java b/fxgl-samples/src/main/java/intermediate/components/PongVsComputerApp.java new file mode 100644 index 0000000000..84611ef7d3 --- /dev/null +++ b/fxgl-samples/src/main/java/intermediate/components/PongVsComputerApp.java @@ -0,0 +1,150 @@ +/* + * FXGL - JavaFX Game Library. The MIT License (MIT). + * Copyright (c) AlmasB (almaslvl@gmail.com). + * See LICENSE for details. + */ + +package intermediate.components; + +import com.almasb.fxgl.app.GameApplication; +import com.almasb.fxgl.app.GameSettings; +import com.almasb.fxgl.entity.Entity; +import com.almasb.fxgl.entity.component.TimerActionComponent; +import javafx.geometry.Point2D; +import javafx.scene.input.KeyCode; +import javafx.scene.paint.Color; +import javafx.scene.shape.Rectangle; +import javafx.scene.text.Text; +import javafx.util.Duration; + +import java.util.Map; + +import static com.almasb.fxgl.dsl.FXGL.*; + +public class PongVsComputerApp extends GameApplication { + + private static final int PADDLE_WIDTH = 30; + private static final int PADDLE_HEIGHT = 100; + private static final int BALL_SIZE = 20; + private static final int PADDLE_SPEED = 5; + private static final int BALL_SPEED = 5; + + private Entity ball; + private Entity playerPaddle; + private Entity computerPaddle; + + @Override + protected void initSettings(GameSettings settings) { + settings.setTitle("Impossible Pong"); + } + + @Override + protected void initInput() { + onKey(KeyCode.W, () -> playerPaddle.translateY(-PADDLE_SPEED)); + onKey(KeyCode.S, () -> playerPaddle.translateY(PADDLE_SPEED)); + } + + @Override + protected void initGameVars(Map vars) { + vars.put("score1", 0); + vars.put("score2", 0); + } + + @Override + protected void initGame() { + ball = spawnBall(getAppWidth() / 2 - BALL_SIZE / 2, getAppHeight() / 2 - BALL_SIZE / 2); + playerPaddle = spawnPlayerBat(0, getAppHeight() / 2 - PADDLE_HEIGHT / 2); + computerPaddle = spawnComputerBat(getAppWidth() - PADDLE_WIDTH, getAppHeight() / 2 - PADDLE_HEIGHT / 2); + } + + @Override + protected void initUI() { + Text textScore1 = getUIFactoryService().newText("", Color.BLACK, 22); + Text textScore2 = getUIFactoryService().newText("", Color.BLACK, 22); + + textScore1.textProperty().bind(getip("score1").asString()); + textScore2.textProperty().bind(getip("score2").asString()); + + addUINode(textScore1, 10, 50); + addUINode(textScore2, getAppWidth() - 30, 50); + } + + @Override + protected void onUpdate(double tpf) { + Point2D velocity = ball.getObject("velocity"); + ball.translate(velocity); + + if (ball.getX() == playerPaddle.getRightX() + && ball.getY() < playerPaddle.getBottomY() + && ball.getBottomY() > playerPaddle.getY()) { + ball.setProperty("velocity", new Point2D(-velocity.getX(), velocity.getY())); + } + + if (ball.getRightX() == computerPaddle.getX() + && ball.getY() < computerPaddle.getBottomY() + && ball.getBottomY() > computerPaddle.getY()) { + ball.setProperty("velocity", new Point2D(-velocity.getX(), velocity.getY())); + } + + if (ball.getX() <= 0) { + inc("score2", +1); + resetBall(); + } + + if (ball.getRightX() >= getAppWidth()) { + inc("score1", +1); + resetBall(); + } + + if (ball.getY() <= 0) { + ball.setY(0); + ball.setProperty("velocity", new Point2D(velocity.getX(), -velocity.getY())); + } + + if (ball.getBottomY() >= getAppHeight()) { + ball.setY(getAppHeight() - BALL_SIZE); + ball.setProperty("velocity", new Point2D(velocity.getX(), -velocity.getY())); + } + } + + private Entity spawnPlayerBat(double x, double y) { + return entityBuilder() + .at(x, y) + .viewWithBBox(new Rectangle(PADDLE_WIDTH, PADDLE_HEIGHT)) + .buildAndAttach(); + } + + private Entity spawnComputerBat(double x, double y) { + TimerActionComponent timerComponent = new TimerActionComponent(); + + /* Move the Y position of the computer bat to the Y position of the ball */ + timerComponent.runAtInterval(() -> { + Entity ball = timerComponent.getEntity().getObject("ballEntity"); + timerComponent.getEntity().setY(ball.getY() - timerComponent.getEntity().getHeight() / 2f); + }, Duration.seconds(1 / 60f)); + + return entityBuilder() + .at(x, y) + .viewWithBBox(new Rectangle(PADDLE_WIDTH, PADDLE_HEIGHT)) + .with(timerComponent) + .with("ballEntity", ball) + .buildAndAttach(); + } + + private Entity spawnBall(double x, double y) { + return entityBuilder() + .at(x, y) + .viewWithBBox(new Rectangle(BALL_SIZE, BALL_SIZE)) + .with("velocity", new Point2D(BALL_SPEED, BALL_SPEED)) + .buildAndAttach(); + } + + private void resetBall() { + ball.setPosition(getAppWidth() / 2 - BALL_SIZE / 2, getAppHeight() / 2 - BALL_SIZE / 2); + ball.setProperty("velocity", new Point2D(BALL_SPEED, BALL_SPEED)); + } + + public static void main(String[] args) { + launch(args); + } +}