diff --git a/fxgl-core/src/main/kotlin/com/almasb/fxgl/core/AsyncService.kt b/fxgl-core/src/main/kotlin/com/almasb/fxgl/core/AsyncService.kt new file mode 100644 index 000000000..7f23b1e64 --- /dev/null +++ b/fxgl-core/src/main/kotlin/com/almasb/fxgl/core/AsyncService.kt @@ -0,0 +1,41 @@ +/* + * FXGL - JavaFX Game Library. The MIT License (MIT). + * Copyright (c) AlmasB (almaslvl@gmail.com). + * See LICENSE for details. + */ + +package com.almasb.fxgl.core + +import java.util.concurrent.CompletableFuture + +/** + * + * @author Jean-Rene Lavoie (jeanrlavoie@gmail.com) + */ +abstract class AsyncService : EngineService() { + + private var asyncTask: CompletableFuture? = null + + /** + * Call the async game update. On next game update, wait for the task to be completed (if not already) and + * call onPostGameUpdateAsync to allow JavaFX thread dependent task handling (e.g. updating the Nodes) + */ + override fun onGameUpdate(tpf: Double) { + asyncTask?.let { onPostGameUpdateAsync(it.get()) } + asyncTask = CompletableFuture.supplyAsync(){ onGameUpdateAsync(tpf) } // Process until next onGameUpdate + } + + /** + * Async game update processing method. + * Warning: This will not run on the main JavaFX thread. This means that any changes done on the Nodes will cause + * an exception. + */ + abstract fun onGameUpdateAsync(tpf: Double): T + + /** + * Async processing Callback. This method is called on next onGameUpdate allowing synchronization between this + * Service async processing and the main JavaFX thread. + */ + open fun onPostGameUpdateAsync(result: T) { } + +} \ No newline at end of file diff --git a/fxgl-core/src/test/kotlin/com/almasb/fxgl/core/AsyncServiceTest.kt b/fxgl-core/src/test/kotlin/com/almasb/fxgl/core/AsyncServiceTest.kt new file mode 100644 index 000000000..d4ebab3e7 --- /dev/null +++ b/fxgl-core/src/test/kotlin/com/almasb/fxgl/core/AsyncServiceTest.kt @@ -0,0 +1,99 @@ +/* + * FXGL - JavaFX Game Library. The MIT License (MIT). + * Copyright (c) AlmasB (almaslvl@gmail.com). + * See LICENSE for details. + */ +@file:Suppress("JAVA_MODULE_DOES_NOT_DEPEND_ON_MODULE") +package com.almasb.fxgl.core + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.greaterThan +import org.hamcrest.Matchers.lessThan +import org.hamcrest.Matchers.both +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import kotlin.system.measureTimeMillis + +/** + * + * @author Jean-Rene Lavoie (jeanrlavoie@gmail.com) + */ +class AsyncServiceTest { + + @Test + fun `Async Service with Unit (Kotlin Void)`() { + val service = object : AsyncService() { + override fun onGameUpdateAsync(tpf: Double) { + Thread.sleep(100) // Processing takes more time than a normal tick + } + } + + // On first call, it'll launch the async process and continue the game loop without affecting the tick + // If it takes less than 5 millis, it's running async + assertThat(measureTimeMillis { service.onGameUpdate(1.0) }.toDouble(), lessThan(7.0)) + + // On the second call, it must wait until the first call is resolved before calling it again (to prevent major desync) + // We expect it to take more than 80 millis + assertThat(measureTimeMillis { service.onGameUpdate(1.0) }.toDouble(), greaterThan(80.0)) + } + + @Test + fun `Async Service with T (String)`() { + var postUpdateValue = "" + val service = object : AsyncService() { + override fun onGameUpdateAsync(tpf: Double): String { + return "Done" + } + + override fun onPostGameUpdateAsync(result: String) { + postUpdateValue = result + } + } + + // On first call, we don't have the postUpdateValue yet + service.onGameUpdate(1.0) + assertEquals(postUpdateValue, "") + + // On second update, we updated the postUpdateValue + service.onGameUpdate(1.0) + assertEquals(postUpdateValue, "Done") + } + + @Test + fun `Async Service parallel`() { + val services = listOf( + object : AsyncService() { + override fun onGameUpdateAsync(tpf: Double) { + Thread.sleep(100) // Processing takes more time than a normal tick + } + }, + object : AsyncService() { + override fun onGameUpdateAsync(tpf: Double) { + Thread.sleep(100) // Processing takes more time than a normal tick + } + }, + object : AsyncService() { + override fun onGameUpdateAsync(tpf: Double) { + Thread.sleep(100) // Processing takes more time than a normal tick + } + } + ) + + // 3 services started in parallel without additional latency + assertThat(measureTimeMillis { + services.forEach { service -> + service.onGameUpdate(1.0) + } + }.toDouble(), lessThan(7.0)) + + // 3 services resolved in approximately 1/3 of what it would take if they were sequentially resolved + assertThat(measureTimeMillis { + services.forEach { service -> + service.onGameUpdate(1.0) + } + }.toDouble(), `is`(both(greaterThan(80.0)).and(lessThan(120.0)))) + } + +} \ No newline at end of file