From 74007d86b029fe5f56147b1bc5f533c621d7422a Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira <97637719+GuilhermeF03@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:04:49 +0100 Subject: [PATCH] Uniformized lifecycle hooks --- .gitignore | 1 + .../libgdx/app/headless/HeadlessHost.kt | 2 +- .../main/kotlin/io/canopy/engine/app/App.kt | 250 ++++++++---------- .../kotlin/io/canopy/engine/app/AppHandle.kt | 108 +------- .../kotlin/io/canopy/engine/app/Screen.kt | 34 +-- .../io/canopy/engine/app/ScreenManager.kt | 25 +- .../io/canopy/engine/app/ScreenRegistry.kt | 16 +- .../io/canopy/engine/core/flows/Context.kt | 3 +- .../core/flows/events/TrackingContext.kt | 4 + .../engine/core/managers/InjectionManager.kt | 4 +- .../io/canopy/engine/core/managers/Manager.kt | 18 +- .../engine/core/managers/ManagersRegistry.kt | 60 ++++- .../engine/core/managers/SceneManager.kt | 16 +- .../io/canopy/engine/core/nodes/Node.kt | 6 +- .../io/canopy/engine/data/parsers/Json.kt | 5 +- .../io/canopy/engine/input/InputManager.kt | 16 +- .../logging/logback/ColoredFieldsConverter.kt | 1 - .../io/canopy/engine/core/nodes/NodeTests.kt | 6 +- .../canopy/engine/data/parsers/TomlTests.kt | 4 +- .../platforms/terminal/app/TerminalApp.kt | 5 +- .../io/canopy/devtools/app/AppTestDriver.kt | 2 +- .../io/canopy/devtools/app/CanopyAppTests.kt | 9 +- .../canopy/devtools/app/CanopyScreenTests.kt | 10 +- 23 files changed, 278 insertions(+), 327 deletions(-) diff --git a/.gitignore b/.gitignore index 6fef137..fabd990 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,4 @@ Thumbs.db **/resource-config.json **/.canopy/ **/bin/ +**/build/ diff --git a/adapters/libgdx/src/main/kotlin/io/canopy/adapters/libgdx/app/headless/HeadlessHost.kt b/adapters/libgdx/src/main/kotlin/io/canopy/adapters/libgdx/app/headless/HeadlessHost.kt index 25492c7..5c0dceb 100644 --- a/adapters/libgdx/src/main/kotlin/io/canopy/adapters/libgdx/app/headless/HeadlessHost.kt +++ b/adapters/libgdx/src/main/kotlin/io/canopy/adapters/libgdx/app/headless/HeadlessHost.kt @@ -13,7 +13,7 @@ object HeadlessHost { val host = object : KtxGame() { override fun create() { super.create() - app.ready() + app.enter() } override fun render() { diff --git a/engine/src/main/kotlin/io/canopy/engine/app/App.kt b/engine/src/main/kotlin/io/canopy/engine/app/App.kt index eb61ea8..d411163 100644 --- a/engine/src/main/kotlin/io/canopy/engine/app/App.kt +++ b/engine/src/main/kotlin/io/canopy/engine/app/App.kt @@ -1,6 +1,6 @@ package io.canopy.engine.app -import java.util.concurrent.CountDownLatch +import kotlin.time.Duration import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import io.canopy.engine.core.CanopyBuildInfo @@ -8,28 +8,16 @@ import io.canopy.engine.core.managers.InjectionManager import io.canopy.engine.core.managers.Manager import io.canopy.engine.core.managers.ManagersRegistry import io.canopy.engine.core.managers.SceneManager -import io.canopy.engine.core.managers.manager -import io.canopy.engine.input.InputManager -import io.canopy.engine.input.binds.InputBind import io.canopy.engine.logging.CanopyLogging import io.canopy.engine.logging.EngineLogs import io.canopy.engine.logging.LogContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout abstract class App protected constructor() { - - /* ============================================================ - * Core systems - * ============================================================ */ - - private val screenRegistry = ScreenRegistry() - - protected val screenManager = ScreenManager() - protected val sceneManager = SceneManager() - /* ============================================================ * Configuration * ============================================================ */ - private var _config: C? = null protected val config: C get() = _config ?: defaultConfig() @@ -42,156 +30,147 @@ abstract class App protected constructor() { private var frame: Long = 0 - private val startedLatch = CountDownLatch(1) - private val finishedLatch = CountDownLatch(1) + private val onStarted = CompletableDeferred() + private val onStopped = CompletableDeferred() private val finished = AtomicBoolean(false) private val backendExitRef = AtomicReference<(() -> Unit)?>(null) private val backendForceRef = AtomicReference<(() -> Unit)?>(null) private val launchThreadRef = AtomicReference(null) - private val launchErrorRef = AtomicReference(null) /* ============================================================ * User callbacks * ============================================================ */ - protected var onReady: (App) -> Unit = {} + protected var onEnter: (App) -> Unit = {} protected var onUpdate: (App, delta: Float) -> Unit = { _, _ -> } protected var onResize: (App, width: Int, height: Int) -> Unit = { _, _, _ -> } protected var onExit: (App) -> Unit = {} - protected var onInputs: (InputManager) -> Unit = {} /* ============================================================ * Builder hooks * ============================================================ */ - protected var managerBuilder: ManagersRegistry.() -> Unit = {} - protected var sceneManagerBuilder: SceneManager.() -> Unit = {} /* ============================================================ * Public handle * ============================================================ */ - val handle: AppHandle = AppHandle( - onRequestExit = { - backendExitRef.get()?.invoke() ?: launchThreadRef.get()?.interrupt() - }, - onForceClose = { - backendForceRef.get()?.invoke() ?: Runtime.getRuntime().halt(0) - }, - onJoin = { timeout, unit -> finishedLatch.await(timeout, unit) }, - onAwaitStarted = { timeout, unit -> startedLatch.await(timeout, unit) } - ) + val handle: AppHandle = object : AppHandle { + + override fun requestExit() { + val exit = backendExitRef.get() + val thread = launchThreadRef.get() + + when { + exit != null -> exit() + thread != null -> thread.interrupt() + } + } + + override fun forceClose() { + val force = backendForceRef.get() + + when { + force != null -> force() + else -> Runtime.getRuntime().halt(0) + } + } + + override suspend fun join() { + onStopped.await() + } + + override suspend fun join(timeout: Duration): Boolean = try { + withTimeout(timeout) { + onStopped.await() + true + } + } catch (_: Exception) { + false + } + + override suspend fun awaitStarted() { + onStarted.await() + } + + override suspend fun awaitStarted(timeout: Duration): Boolean = try { + withTimeout(timeout) { + onStarted.await() + true + } + } catch (_: Exception) { + false + } + } /* ============================================================ - * Extension hooks + * Hooks * ============================================================ */ - /** - * Hook for subclasses to provide additional managers before setup. - * - * Do not include [InjectionManager], [SceneManager], or [ScreenManager] here. - * Those are owned by [App]. - */ - protected open fun collectManagers(): List = emptyList() - - /** - * Hook for subclasses to configure the shared [SceneManager] before setup. - */ - protected open fun configureSceneManager(sceneManager: SceneManager) = Unit - - /** - * Hook called after managers and screens are ready. - */ - protected open fun afterReady() = Unit - - /** - * Hook called before the user update callback and screen frame update. - */ + protected open fun afterEnter() = Unit protected open fun beforeUpdate(delta: Float) = Unit - - /** - * Hook called after the user resize callback. - */ protected open fun afterResize(width: Int, height: Int) = Unit - - /** - * Hook called before teardown begins. - */ protected open fun beforeExit() = Unit - /** - * Platform-specific app entrypoint. - */ + protected open fun provideManagers(): List = emptyList() + protected open fun SceneManager.configureSceneManager() = Unit + protected abstract fun internalLaunch(config: C, vararg args: String) /* ============================================================ - * Platform lifecycle + * Lifecycle * ============================================================ */ - /** - * Called by the platform runtime when the app is actually starting. - * - * Final in behavior even if not marked final: subclasses should customize via hooks. - */ - fun ready() { - CanopyLogging.init( - CanopyLogging.Config( - engineVersion = CanopyBuildInfo.projectVersion + fun enter() { + try { + CanopyLogging.init( + CanopyLogging.Config( + engineVersion = CanopyBuildInfo.projectVersion + ) ) - ) - val backendName = this::class.simpleName ?: "unknown" - LogContext.with("backend" to backendName) { - EngineLogs.lifecycle.info { "Booting Canopy..." } + val backendName = this::class.simpleName ?: "unknown" + LogContext.with("backend" to backendName) { + EngineLogs.lifecycle.info { "Booting Canopy..." } - sceneManager.apply(sceneManagerBuilder) - configureSceneManager(sceneManager) + ManagersRegistry.withScope { + provideManagers().forEach(::register) + +InjectionManager() + +ScreenManager() + +SceneManager().also { it.configureSceneManager() } + managerBuilder() + } - ManagersRegistry.withScope { - managerBuilder() - collectManagers().forEach(::register) - +InjectionManager() - +sceneManager - +screenManager - } + onEnter(this@App) + afterEnter() - screenRegistry.setup() + onStarted.safeComplete() - if (ManagersRegistry.has(InputManager::class)) { - onInputs(manager()) - } - - onReady(this@App) - afterReady() - - startedLatch.countDown() - - EngineLogs.lifecycle.info("event" to "app.launch.init") { - "Application started." + EngineLogs.lifecycle.info("event" to "app.launch.init") { + "Application started." + } } + } catch (t: Throwable) { + onStarted.safeFail(t) + onStopped.safeFail(t) + throw t } } - /** - * Called by the platform runtime every frame/tick. - */ fun update(delta: Float) { frame++ LogContext.with("frame" to frame) { + beforeUpdate(delta) onUpdate(this@App, delta) } - screenManager.frame(delta) + ManagersRegistry.update(delta) } - /** - * Called by the platform runtime when the surface changes size. - */ fun resize(width: Int, height: Int) { - sceneManager.resize(width, height) - screenManager.resize(width, height) + ManagersRegistry.resize(width, height) onResize(this, width, height) afterResize(width, height) @@ -203,30 +182,29 @@ abstract class App protected constructor() { ) { "Screen resized." } } - /** - * Called by the platform runtime on shutdown. - */ fun exit() { try { EngineLogs.lifecycle.info("event" to "app.dispose") { "Disposing app" } beforeExit() - ManagersRegistry.teardown() + ManagersRegistry.exit() CanopyLogging.end(reason = "normal") } catch (t: Throwable) { CanopyLogging.end(reason = "crash", t = t) + onStopped.safeFail(t) throw t } finally { try { onExit(this) } finally { + onStopped.safeComplete() markFinished() } } } /* ============================================================ - * Launch control + * Launch * ============================================================ */ fun launch(vararg args: String) { @@ -234,27 +212,21 @@ abstract class App protected constructor() { } fun launchAsync(threadName: String = "canopy-app", vararg args: String): AppHandle { - val runnable = { + val thread = Thread({ try { internalLaunch(config, *args) } catch (t: Throwable) { - launchErrorRef.set(t) - startedLatch.countDown() - markFinished() + onStarted.safeFail(t) + onStopped.safeFail(t) throw t } - } - - val thread = Thread(runnable, threadName).apply { + }, threadName).apply { isDaemon = false } launchThreadRef.set(thread) thread.start() - startedLatch.await() - launchErrorRef.get()?.let { throw it } - return handle } @@ -264,15 +236,15 @@ abstract class App protected constructor() { } /* ============================================================ - * Configuration DSL + * DSL * ============================================================ */ fun config(newConfig: C) { _config = newConfig } - fun onReady(handler: App.() -> Unit) { - onReady = handler + fun onEnter(handler: App.() -> Unit) { + onEnter = handler } fun onUpdate(handler: App.(Float) -> Unit) { @@ -287,18 +259,6 @@ abstract class App protected constructor() { onExit = handler } - fun screens(handler: ScreenRegistry.() -> Unit) { - screenRegistry.registerSetupCallback(handler) - } - - fun inputs(vararg mappings: Pair>) { - onInputs = { manager -> manager.mapActions(*mappings) } - } - - fun sceneManager(handler: SceneManager.() -> Unit) { - sceneManagerBuilder = handler - } - fun managers(handler: ManagersRegistry.() -> Unit) { managerBuilder = handler } @@ -308,8 +268,14 @@ abstract class App protected constructor() { * ============================================================ */ private fun markFinished() { - if (finished.compareAndSet(false, true)) { - finishedLatch.countDown() - } + finished.compareAndSet(false, true) + } + + private fun CompletableDeferred.safeComplete() { + if (!isCompleted) complete(Unit) + } + + private fun CompletableDeferred.safeFail(t: Throwable) { + if (!isCompleted) completeExceptionally(t) } } diff --git a/engine/src/main/kotlin/io/canopy/engine/app/AppHandle.kt b/engine/src/main/kotlin/io/canopy/engine/app/AppHandle.kt index f5b1d6f..af41760 100644 --- a/engine/src/main/kotlin/io/canopy/engine/app/AppHandle.kt +++ b/engine/src/main/kotlin/io/canopy/engine/app/AppHandle.kt @@ -1,106 +1,14 @@ package io.canopy.engine.app -import java.util.concurrent.TimeUnit +import kotlin.time.Duration -/** - * Handle returned when launching a Canopy app asynchronously. - * - * This object allows external callers (tests, launchers, tools) to: - * - * - Request a graceful shutdown of the application - * - Force-close the application if it becomes unresponsive - * - Wait for the application to start or finish - * - * Backend implementations provide the actual behavior via the injected - * callback functions. - * - * Typical usage: - * - * ``` - * val handle = app.launchAsync() - * - * handle.awaitStarted(5, TimeUnit.SECONDS) - * - * // ... interact with the app ... - * - * handle.requestExit() - * handle.join() - * ``` - * - * This class implements [AutoCloseable] so it can be used in `use {}` blocks: - * - * ``` - * app.launchAsync().use { handle -> - * handle.awaitStarted(5, TimeUnit.SECONDS) - * } - * ``` - */ -open class AppHandle( - /** Called when a graceful shutdown is requested. */ - private val onRequestExit: () -> Unit, +interface AppHandle { + fun requestExit() + fun forceClose() - /** Called when the app must be forcefully terminated. */ - private val onForceClose: () -> Unit = onRequestExit, + suspend fun join() + suspend fun join(timeout: Duration): Boolean - /** - * Waits for the application to exit. - * - * Returns `true` if the app finished before the timeout. - */ - private val onJoin: (timeout: Long, unit: TimeUnit) -> Boolean = { _, _ -> true }, - - /** - * Waits for the application to finish booting. - * - * Returns `true` if startup completed before the timeout. - */ - private val onAwaitStarted: (timeout: Long, unit: TimeUnit) -> Boolean = { _, _ -> true }, -) : AutoCloseable { - - /** - * Requests a graceful application shutdown. - * - * Backends should interpret this as: - * - closing the window - * - stopping the render loop - * - allowing cleanup to run normally - */ - fun requestExit() = onRequestExit() - - /** - * Immediately terminates the application. - * - * This should only be used as a last resort if graceful shutdown fails. - */ - fun forceClose() = onForceClose() - - /** - * Blocks indefinitely until the application exits. - */ - fun join() { - onJoin(Long.MAX_VALUE, TimeUnit.DAYS) - } - - /** - * Blocks until the application exits or the timeout expires. - * - * @return `true` if the application exited before the timeout - */ - fun join(timeout: Long, unit: TimeUnit = TimeUnit.MILLISECONDS): Boolean = onJoin(timeout, unit) - - /** - * Allows this handle to be used with `use {}` blocks. - * Closing the handle requests a graceful exit. - */ - override fun close() = requestExit() - - /** - * Waits until the application has fully started. - * - * Useful when launching asynchronously, and you need to ensure the - * engine has finished initialization before interacting with it. - * - * @return `true` if the app started before the timeout - */ - fun awaitStarted(timeout: Long, unit: TimeUnit = TimeUnit.MILLISECONDS): Boolean = onAwaitStarted(timeout, unit) + suspend fun awaitStarted() + suspend fun awaitStarted(timeout: Duration): Boolean } diff --git a/engine/src/main/kotlin/io/canopy/engine/app/Screen.kt b/engine/src/main/kotlin/io/canopy/engine/app/Screen.kt index 4bc091f..0890840 100644 --- a/engine/src/main/kotlin/io/canopy/engine/app/Screen.kt +++ b/engine/src/main/kotlin/io/canopy/engine/app/Screen.kt @@ -1,20 +1,17 @@ package io.canopy.engine.app -import io.canopy.engine.core.managers.SceneManager -import io.canopy.engine.core.managers.manager - /** * Base screen abstraction for Canopy applications. * * This class is platform-agnostic and defines a simple lifecycle: * - * - [onEnter] → called when the screen becomes active - * - [onFrame] → called every frame + * - [onActive] → called when the screen becomes active + * - [onUpdate] → called every frame * - [onResize] → called when the surface changes size * - [onExit] → called when the screen is no longer active * - [dispose] → called when the screen is destroyed * - * Additionally, [setup] is guaranteed to run only once, + * Additionally, [onEnter] is guaranteed to run only once, * the first time the screen is entered. */ abstract class Screen { @@ -24,39 +21,32 @@ abstract class Screen { /** * Called once when the screen is first entered. */ - open fun setup() {} + open fun onEnter() {} /** * Called when the screen becomes active. */ - open fun onEnter() { - if (!setupCalled) { - setup() - setupCalled = true - } - } + open fun onActive() {} + + /** + * Called when the screen is no longer active. + */ + open fun onInactive() {} /** * Called every frame. * * @param delta Time since last frame (in seconds) */ - open fun onFrame(delta: Float) { - manager().tick(delta) - } + open fun onUpdate(delta: Float) {} /** * Called when the screen is resized. */ open fun onResize(width: Int, height: Int) {} - /** - * Called when the screen is no longer active. - */ - open fun onExit() {} - /** * Called when the screen is destroyed. */ - open fun dispose() {} + open fun onExit() {} } diff --git a/engine/src/main/kotlin/io/canopy/engine/app/ScreenManager.kt b/engine/src/main/kotlin/io/canopy/engine/app/ScreenManager.kt index 088190d..174fe59 100644 --- a/engine/src/main/kotlin/io/canopy/engine/app/ScreenManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/app/ScreenManager.kt @@ -8,6 +8,7 @@ class ScreenManager : Manager { /* ============================================================ * Registry * ============================================================ */ + internal val screenRegistry = ScreenRegistry() private val screens = linkedMapOf, Screen>() @@ -54,11 +55,15 @@ class ScreenManager : Manager { * Frame lifecycle * ============================================================ */ - fun frame(delta: Float) { - current?.onFrame(delta) + override fun onEnter() { + screenManagerBuilder() } - fun resize(width: Int, height: Int) { + override fun onUpdate(delta: Float) { + current?.onUpdate(delta) + } + + override fun onResize(width: Int, height: Int) { current?.onResize(width, height) } @@ -66,13 +71,19 @@ class ScreenManager : Manager { * Teardown * ============================================================ */ - override fun teardown() { - super.teardown() - + override fun onExit() { current?.onExit() current = null - screens.values.forEach { it.dispose() } + screens.values.forEach { it.onExit() } screens.clear() } + + companion object { + internal var screenManagerBuilder: ScreenManager.() -> Unit = {} + } +} + +fun App<*>.screens(handler: ScreenRegistry.() -> Unit) { + ScreenManager.screenManagerBuilder = { screenRegistry.apply(handler) } } diff --git a/engine/src/main/kotlin/io/canopy/engine/app/ScreenRegistry.kt b/engine/src/main/kotlin/io/canopy/engine/app/ScreenRegistry.kt index acdaf2d..8b8d423 100644 --- a/engine/src/main/kotlin/io/canopy/engine/app/ScreenRegistry.kt +++ b/engine/src/main/kotlin/io/canopy/engine/app/ScreenRegistry.kt @@ -30,15 +30,15 @@ class ScreenRegistry internal constructor() { * Setup * ============================================================ */ - private var setupCallback: ScreenRegistry.() -> Unit = {} +// private var setupCallback: ScreenRegistry.() -> Unit = {} +// +// fun registerSetupCallback(callback: ScreenRegistry.() -> Unit = {}) { +// setupCallback = callback +// } - fun registerSetupCallback(callback: ScreenRegistry.() -> Unit = {}) { - setupCallback = callback - } - - fun setup() { - setupCallback() - } +// fun setup() { +// setupCallback() +// } /* ============================================================ * Registration diff --git a/engine/src/main/kotlin/io/canopy/engine/core/flows/Context.kt b/engine/src/main/kotlin/io/canopy/engine/core/flows/Context.kt index 3c3f90a..2abd874 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/flows/Context.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/flows/Context.kt @@ -1,7 +1,6 @@ package io.canopy.engine.core.flows -import kotlin.jvm.Throws -import java.util.UUID +import java.util.* import io.canopy.engine.core.nodes.Node /** diff --git a/engine/src/main/kotlin/io/canopy/engine/core/flows/events/TrackingContext.kt b/engine/src/main/kotlin/io/canopy/engine/core/flows/events/TrackingContext.kt index 0388d17..6cb6b30 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/flows/events/TrackingContext.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/flows/events/TrackingContext.kt @@ -1,5 +1,9 @@ package io.canopy.engine.core.flows.events +import io.canopy.engine.core.flows.events.TrackingContext.pop +import io.canopy.engine.core.flows.events.TrackingContext.register +import io.canopy.engine.core.flows.events.TrackingContext.untrack + /** * Thread-local stack of dependency-tracking frames. * diff --git a/engine/src/main/kotlin/io/canopy/engine/core/managers/InjectionManager.kt b/engine/src/main/kotlin/io/canopy/engine/core/managers/InjectionManager.kt index d7c907c..165c6c0 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/managers/InjectionManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/managers/InjectionManager.kt @@ -113,11 +113,11 @@ class InjectionManager : Manager { * Manager lifecycle * ============================================================ */ - override fun setup() { + override fun onEnter() { log.debug("event" to "di.setup") { "Setup" } } - override fun teardown() { + override fun onExit() { log.debug("event" to "di.teardown", "size" to providers.size) { "Teardown" } providers.clear() } diff --git a/engine/src/main/kotlin/io/canopy/engine/core/managers/Manager.kt b/engine/src/main/kotlin/io/canopy/engine/core/managers/Manager.kt index 16bc072..dc326fb 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/managers/Manager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/managers/Manager.kt @@ -9,8 +9,8 @@ package io.canopy.engine.core.managers * Managers are typically registered through [ManagersRegistry] and follow * a simple lifecycle: * - * 1. [setup] is called during application startup. - * 2. [teardown] is called when the application shuts down. + * 1. [onEnter] is called during application startup. + * 2. [onExit] is called when the application shuts down. * * Implementations may override these methods to allocate or release * resources as needed. @@ -23,12 +23,22 @@ interface Manager { * Use this method to allocate resources, register services, * or perform any initialization logic. */ - fun setup() = Unit + fun onEnter() = Unit + + /** + * Called on each app frame + */ + fun onUpdate(delta: Float) = Unit + + /** + * Called on screen resize + */ + fun onResize(width: Int, height: Int) = Unit /** * Called when the manager is being shut down. * * Use this method to release resources or reset internal state. */ - fun teardown() = Unit + fun onExit() = Unit } diff --git a/engine/src/main/kotlin/io/canopy/engine/core/managers/ManagersRegistry.kt b/engine/src/main/kotlin/io/canopy/engine/core/managers/ManagersRegistry.kt index c582f2d..3626fbb 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/managers/ManagersRegistry.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/managers/ManagersRegistry.kt @@ -72,7 +72,7 @@ object ManagersRegistry { return resolved as T } - fun setup() { + fun enter() { log.info("event" to "managers.setup", "registered" to managers.size) { "Bootstrapping managers" } @@ -81,14 +81,60 @@ object ManagersRegistry { val name = manager::class.simpleName ?: "UnknownManager" LogContext.with("manager" to name) { log.debug { "setup()" } - manager.setup() + manager.onEnter() } } log.info("event" to "managers.setup.done") { "Finished bootstrapping managers" } } - fun teardown() { + fun update(delta: Float) { + LogContext.with("delta" to delta, "registered" to managers.size) { + log.trace("event" to "managers.update") { "Updating managers" } + } + + managers.values.forEach { manager -> + val name = manager::class.simpleName ?: "UnknownManager" + + try { + LogContext.with("manager" to name, "delta" to delta) { + manager.onUpdate(delta) + } + } catch (t: Throwable) { + log.error( + t = t, + "event" to "manager.update.error", + "manager" to name + ) { "Manager update failed" } + throw t + } + } + } + + fun resize(width: Int, height: Int) { + LogContext.with("width" to width, "height" to height, "registered" to managers.size) { + log.info("event" to "managers.resize") { "Resizing managers" } + } + + managers.values.forEach { manager -> + val name = manager::class.simpleName ?: "UnknownManager" + + try { + LogContext.with("manager" to name, "width" to width, "height" to height) { + manager.onResize(width, height) + } + } catch (t: Throwable) { + log.error( + t = t, + "event" to "manager.resize.error", + "manager" to name + ) { "Manager resize failed" } + throw t + } + } + } + + fun exit() { log.info("event" to "managers.teardown", "registered" to managers.size) { "Tearing down managers" } @@ -97,7 +143,7 @@ object ManagersRegistry { val name = manager::class.simpleName ?: "UnknownManager" LogContext.with("manager" to name) { log.debug { "teardown()" } - manager.teardown() + manager.onExit() } } @@ -109,9 +155,9 @@ object ManagersRegistry { fun withScope(block: ManagersRegistry.() -> Unit) { log.info("event" to "managers.scope") { "Creating scoped Managers registry..." } - teardown() + exit() block() - setup() + enter() log.info("event" to "managers.scope.done") { "Finished creating scoped Managers registry" } } @@ -194,10 +240,8 @@ object ManagersRegistry { } private fun KClass<*>.isSubclassOfManager(): Boolean = Manager::class.java.isAssignableFrom(this.java) - private fun KClass<*>.isConcreteManagerLookupType(): Boolean = isSubclassOfManager() && this != Manager::class } inline fun manager(): T = ManagersRegistry.getManager(T::class) - inline fun lazyManager() = lazy { manager() } diff --git a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt index 2ab016e..fd12e68 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt @@ -1,6 +1,7 @@ package io.canopy.engine.core.managers import kotlin.reflect.KClass +import io.canopy.engine.app.App import io.canopy.engine.core.flows.events.asSignal import io.canopy.engine.core.flows.events.event import io.canopy.engine.core.nodes.Node @@ -30,6 +31,8 @@ import io.canopy.engine.math.Vector2 class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: SceneManager.() -> Unit = {}) : Manager { + private var sceneManagerBuilder: SceneManager.() -> Unit = {} + /** Dedicated subsystem logger (routable + consistent). */ private val log = EngineLogs.subsystem("scene") @@ -378,7 +381,7 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: * - nodeUpdate(delta) * - FramePost systems */ - fun tick(delta: Float) { + override fun onUpdate(delta: Float) { val root = currScene ?: return LogContext.with( @@ -425,7 +428,7 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: /** * Emits resize event for listeners (UI/layout/camera systems). */ - fun resize(width: Int, height: Int) { + override fun onResize(width: Int, height: Int) { onResize.emit(width, height) log.debug("event" to "scene.resize", "width" to width, "height" to height) { "Resize" } } @@ -447,18 +450,23 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: * Manager lifecycle * ============================================================ */ - override fun setup() { + override fun onEnter() { log.info("event" to "sceneManager.setup", "physicsStep" to physicsStep) { "Setup" } // Allow callers to register systems, groups, initial scene, etc. + sceneManagerBuilder() this.block() // Notify systems that they've been registered with the scene manager. systems.values.flatten().forEach(TreeSystem::onRegister) } - override fun teardown() { + override fun onExit() { log.info("event" to "sceneManager.teardown") { "Teardown" } systems.values.flatten().forEach(TreeSystem::onUnregister) } + + fun App<*>.sceneManager(handler: SceneManager.() -> Unit) { + sceneManagerBuilder = handler + } } diff --git a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt index c972e90..07b55ce 100644 --- a/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt +++ b/engine/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt @@ -361,9 +361,11 @@ abstract class Node> protected constructor( current = when (part) { // Start here "", "." -> current + // Go back one node ".." -> current?.findVisibleParent() ?: throw IllegalArgumentException("No parent for path: $path") + // Paths (ex: [a,b,c]) else -> { val node = current @@ -457,7 +459,7 @@ abstract class Node> protected constructor( * - default components/behavior * - setting initial transforms */ - open fun create() {} + open fun nodeInit() {} private var built = false @@ -484,7 +486,7 @@ abstract class Node> protected constructor( currentParent.set(this) try { - create() + nodeInit() block(this as N) } finally { currentParent.set(oldParent) diff --git a/engine/src/main/kotlin/io/canopy/engine/data/parsers/Json.kt b/engine/src/main/kotlin/io/canopy/engine/data/parsers/Json.kt index 92c0b68..5b8a4aa 100644 --- a/engine/src/main/kotlin/io/canopy/engine/data/parsers/Json.kt +++ b/engine/src/main/kotlin/io/canopy/engine/data/parsers/Json.kt @@ -3,11 +3,8 @@ package io.canopy.engine.data.parsers import io.canopy.engine.data.assets.AssetEntry import io.canopy.engine.data.assets.WritableAssetEntry import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.* import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonBuilder -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.serializer diff --git a/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt b/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt index 53be8f9..8791f97 100644 --- a/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt +++ b/engine/src/main/kotlin/io/canopy/engine/input/InputManager.kt @@ -1,16 +1,18 @@ package io.canopy.engine.input +import io.canopy.engine.app.App import io.canopy.engine.core.managers.Manager +import io.canopy.engine.core.managers.manager import io.canopy.engine.data.saving.registerSaveModule -import io.canopy.engine.input.InputMapper import io.canopy.engine.input.binds.InputBind import io.canopy.engine.input.binds.InputData import io.canopy.engine.math.Vector2 abstract class InputManager : Manager { - private val mapper = InputMapper() + internal var onInputs: () -> Unit = {} + private val _actionStates = mutableMapOf() val actionStates get() = _actionStates.toMap() @@ -127,4 +129,14 @@ abstract class InputManager : Manager { else -> InputState.Released } } + + override fun onEnter() { + onInputs() + } +} + +fun App<*>.inputs(vararg mappings: Pair>) { + manager().let { + it.onInputs = { it.mapActions(*mappings) } + } } diff --git a/engine/src/main/kotlin/io/canopy/engine/logging/logback/ColoredFieldsConverter.kt b/engine/src/main/kotlin/io/canopy/engine/logging/logback/ColoredFieldsConverter.kt index b112fd4..c4dd268 100644 --- a/engine/src/main/kotlin/io/canopy/engine/logging/logback/ColoredFieldsConverter.kt +++ b/engine/src/main/kotlin/io/canopy/engine/logging/logback/ColoredFieldsConverter.kt @@ -1,6 +1,5 @@ package io.canopy.engine.logging.logback -import ch.qos.logback.classic.pattern.MessageConverter import ch.qos.logback.classic.spi.ILoggingEvent /** diff --git a/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt b/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt index dddeddb..d00dceb 100644 --- a/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt +++ b/engine/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt @@ -226,7 +226,7 @@ class NodeTests { // Verifies create() can define internal structure. class CustomScene(name: String = "custom", block: CustomScene.() -> Unit = {}) : Node(name, block = block) { - override fun create() { + override fun nodeInit() { EmptyNode("empty") } } @@ -246,7 +246,7 @@ class NodeTests { // Verifies patch() can locate and mutate internally created nodes by path. class CustomScene(name: String = "custom", block: CustomScene.() -> Unit = {}) : Node(name, block = block) { - override fun create() { + override fun nodeInit() { EmptyNode2D("empty") } } @@ -272,7 +272,7 @@ class NodeTests { class CustomScene(name: String, block: CustomScene.() -> Unit = {}) : Node(name, block = block) { - override fun create() { + override fun nodeInit() { behavior(onReady = { wasCalled = true }) } } diff --git a/engine/src/test/kotlin/io/canopy/engine/data/parsers/TomlTests.kt b/engine/src/test/kotlin/io/canopy/engine/data/parsers/TomlTests.kt index d73dad6..454c5ba 100644 --- a/engine/src/test/kotlin/io/canopy/engine/data/parsers/TomlTests.kt +++ b/engine/src/test/kotlin/io/canopy/engine/data/parsers/TomlTests.kt @@ -2,9 +2,7 @@ package io.canopy.engine.data.core.parsers import io.canopy.engine.data.parsers.Toml import kotlinx.serialization.Serializable -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows diff --git a/platforms/terminal/src/main/kotlin/io/canopy/platforms/terminal/app/TerminalApp.kt b/platforms/terminal/src/main/kotlin/io/canopy/platforms/terminal/app/TerminalApp.kt index 04160a1..4261a14 100644 --- a/platforms/terminal/src/main/kotlin/io/canopy/platforms/terminal/app/TerminalApp.kt +++ b/platforms/terminal/src/main/kotlin/io/canopy/platforms/terminal/app/TerminalApp.kt @@ -6,7 +6,6 @@ import io.canopy.adapters.libgdx.data.assets.GdxAssetsManager import io.canopy.adapters.libgdx.input.GdxInputManager import io.canopy.engine.app.App import io.canopy.engine.app.AppConfig -import io.canopy.engine.core.managers.Manager import io.canopy.engine.logging.EngineLogs class TerminalApp internal constructor() : App() { @@ -21,8 +20,8 @@ class TerminalApp internal constructor() : App() { title = "Terminal Canopy App" ) - override fun collectManagers(): List = listOf( - inputManager, + override fun provideManagers() = listOf( + // inputManager, assetsManager ) diff --git a/tooling/devtools/src/main/kotlin/io/canopy/devtools/app/AppTestDriver.kt b/tooling/devtools/src/main/kotlin/io/canopy/devtools/app/AppTestDriver.kt index ed4dffe..e55ab2b 100644 --- a/tooling/devtools/src/main/kotlin/io/canopy/devtools/app/AppTestDriver.kt +++ b/tooling/devtools/src/main/kotlin/io/canopy/devtools/app/AppTestDriver.kt @@ -6,7 +6,7 @@ import io.canopy.platforms.headless.app.HeadlessApp import io.canopy.platforms.headless.app.headlessApp class AppTestDriver internal constructor(private val app: App) { - fun start() = app.ready() + fun start() = app.enter() fun frame(delta: Float) = app.update(delta) fun resize(w: Int, h: Int) = app.resize(w, h) fun stop() = app.exit() diff --git a/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/CanopyAppTests.kt b/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/CanopyAppTests.kt index f2f459b..d206289 100644 --- a/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/CanopyAppTests.kt +++ b/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/CanopyAppTests.kt @@ -2,7 +2,8 @@ package io.canopy.devtools.app import kotlin.test.Test import kotlin.test.assertTrue -import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.runBlocking class CanopyAppTests { @@ -13,15 +14,15 @@ class CanopyAppTests { // Wait until the app has completed boot and the backend exit hooks are installed. assertTrue( - handle.awaitStarted(500, TimeUnit.MILLISECONDS), - "App didn't start within 500ms (backend hooks may not be installed)" + handle.awaitStarted(2.seconds), + "App didn't start within ${2.seconds} (backend hooks may not be installed)" ) // Prefer graceful shutdown in tests; forceClose is a last resort. handle.requestExit() // If you want to be extra defensive in CI, you can fall back to forceClose on timeout: - val exited = handle.join(2_000, TimeUnit.MILLISECONDS) + val exited = handle.join(2_000.milliseconds) if (!exited) { handle.forceClose() handle.join() diff --git a/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/CanopyScreenTests.kt b/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/CanopyScreenTests.kt index d1d0a0f..81f3127 100644 --- a/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/CanopyScreenTests.kt +++ b/tooling/devtools/src/test/kotlin/io/canopy/devtools/app/CanopyScreenTests.kt @@ -2,17 +2,19 @@ package io.canopy.devtools.app import kotlin.test.Test import kotlin.test.assertTrue -import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds import io.canopy.engine.app.Screen +import io.canopy.engine.app.screens +import kotlinx.coroutines.runBlocking class CanopyScreenTests { @Test - fun `screen setup should run when screen starts`() { + fun `screen setup should run when screen starts`() = runBlocking { var screenWasCreated = false val screen = object : Screen() { - override fun setup() { + override fun onEnter() { screenWasCreated = true } } @@ -25,7 +27,7 @@ class CanopyScreenTests { // Wait until the app finished booting assertTrue( - handle.awaitStarted(1, TimeUnit.SECONDS), + handle.awaitStarted(2.seconds), "App failed to start in time" )