diff --git a/README.md b/README.md
index 6458d86..dea28db 100644
--- a/README.md
+++ b/README.md
@@ -1,58 +1,145 @@
-# Canopy Engine
+
+
+
----
+
+ Canopy is a modular 2D game engine written in Kotlin.
+
-
-
-
+ Built around node-based architecture, composable behaviors, and reactive state systems.
-## π² Node-based declarative game engine built in Kotlin π²
+
+
+
+
+
+
+---
+
+# π² What is Canopy?
+
+**Canopy** is a modern 2D engine designed for developers who want:
+
+- a **clear architecture**
+- **modular gameplay systems**
+- **Kotlin-first development**
+- a **composable runtime model**
+
+Instead of large monolithic objects, Canopy encourages building games from **small composable pieces**.
+
+---
+
+# Core Architecture
+
+Canopy is structured around a few simple ideas:
+
+```text
+Application
+ β
+Scene Manager
+ β
+Scene Root
+ β
+Node Tree
+ β
+Behaviors
+ β
+Tree Systems
+````
+
+Additional runtime systems provide:
+
+```
+Signals
+Events
+Contexts
+Managers
+Data Pipelines
+```
+
+This architecture allows game logic to remain **modular and scalable**.
-[](https://github.com/canopyengine/canopy#license)
-
+---
+
+# Example Scene
+
+Scenes are built using a Kotlin DSL.
+
+```kotlin
+EmptyNode("root") {
+
+ Player {
+ behavior(PlayerController())
+ behavior(Move())
+ }
+
+ Enemy()
-[//]: # ([](https://crates.io/crates/bevy))
-[//]: # ([](https://crates.io/crates/bevy))
-[//]: # ([](https://docs.rs/bevy/latest/bevy/))
-[//]: # ([](https://github.com/canopyengine/canopy/actions))
+ UI()
+
+}.asSceneRoot()
+```
+
+Nodes define **structure**, while behaviors attach **gameplay logic**.
---
-**[Canopy](https://github.com/canopyengine/canopy) is a node-based, declarative 2D game engine built in
-[Kotlin](https://kotlinlang.org/), on top of the [LibGDX](https://libgdx.com/) framework and inspired by [Godot](https://godotengine.org).**
-It's designed to be **Kotlin-native**, built with declarative APIs, node composition, and reactive patterns in mind to
-create games in a clean, expressive, and maintainable way.
+# Documentation
-**Canopy** focuses on delivering a **simple yet powerful** experience, giving developers the flexibility to build complex
-systems without sacrificing clarity or control.
+This repository hosts the **official documentation** for the engine.
-[Oficial docs](http://github.com/canopyengine/canopy-docs)
+| Section | Description |
+| --------------------------------------------- | ---------------------- |
+| [Introduction](docs/articles/introduction.md) | Overview of the engine |
+| [Full Documentation](markdown/index.md) | Complete engine manual |
+| [Release Notes](docs/releases/releases.md) | Changelogs |
+| [Roadmap](docs/roadmap.md) | Planned development |
---
-## Design Goals
+# Current Status
+
+β οΈ **Canopy is currently in early development.**
-* Capable: Offer a complete set of 2D tools.
-* Simple: Easy for beginners, flexible for experienced users.
-* Modular: Use only what you need, replace what you don't.
-* Fast: Your game should feel quick and snappy.
-* Productive: Dev experience should be quick, and not bound by long compilation times.
+The architecture is evolving and both the engine and documentation may introduce breaking changes.
+
+You can follow development progress in the [roadmap](docs/roadmap.md).
---
-## β οΈ Work in progress β οΈ
+# Minimum Kotlin Version
+
+```
+Kotlin 2.3.10
+```
-**Canopy** is still a **work in progress**, and the **current version** is still unusable as is. Following the next weeks,
-the goal will be to release a **Headless Version** capable of running the core features in the **terminal**.
-See more details [here](https://github.com/canopyengine/canopy-docs/blob/main/docs/roadmap.md).
+Canopy generally tracks the **latest stable Kotlin release**.
---
-#### **Minimum Supported Kotlin Version**: **2.3.10**
+# Project Goals
-> **Canopy** development aims to follow **Kotlin**'s improvements, so the minimum
-supported version will usually be the latest **stable** version.
+The engine aims to provide:
+* a **clean runtime architecture**
+* **modular gameplay composition**
+* a **modern reactive state system**
+* a **flexible content pipeline**
+Canopy prioritizes **clarity and maintainability** over complexity.
+
+---
+
+# License
+
+Canopy is licensed under the **MIT License**.
+
+See the [LICENSE](LICENSE) file for details.
+
+---
+
+
+ Canopy Engine β’ 2026
+
diff --git a/docs/misc/canopy_review_summary.md b/docs/misc/canopy_review_summary.md
new file mode 100644
index 0000000..185fcdf
--- /dev/null
+++ b/docs/misc/canopy_review_summary.md
@@ -0,0 +1,280 @@
+# Canopy review summary
+
+## Features / areas to review or change
+
+### 1. Reduce `Node` centralization
+**Problem:** `Node` currently carries too many responsibilities: tree structure, transforms, groups, behavior hosting, lifecycle, path logic, prefab semantics, and scene integration.
+
+**Goal:** Keep `Node` ergonomic, but make it less mandatory and less overloaded.
+
+**Suggested changes:**
+- Introduce a smaller base node abstraction for identity, parent/children, and lifecycle.
+- Move optional concerns into capabilities or helpers, such as transform, behavior hosting, and group membership.
+- Keep the current `Node` as the default high-level node type.
+- Favor composition for optional features instead of forcing every node into the same shape.
+
+### 2. Separate construction, build, attachment, and activation
+**Problem:** object creation, subtree building, parent attachment, and lifecycle activation are tightly coupled.
+
+**Goal:** Make lifecycle phases explicit and independently controllable.
+
+**Suggested changes:**
+- Separate these phases:
+ 1. instantiate
+ 2. build/configure
+ 3. attach
+ 4. activate
+- Add explicit primitives such as `buildSubtree()`, `attachChild()`, `enterTreeSubtree()`, and `readySubtree()`.
+- Keep todayβs one-step convenience API as sugar over those lower-level operations.
+
+### 3. Make manager lookup instance-based first, global second
+**Problem:** `ManagersRegistry` is convenient, but global state can become architectural lock-in.
+
+**Goal:** Preserve convenience while enabling multiple app instances, easier tests, and isolated runtime scopes.
+
+**Suggested changes:**
+- Introduce an instance-based `ManagerContainer`.
+- Let `CanopyApp` own its own container.
+- Keep `ManagersRegistry` as the default global convenience container.
+- Make sure every global access path has an explicit instance-based equivalent.
+
+### 4. Replace raw string context keys with typed keys
+**Problem:** string-keyed context is flexible, but becomes brittle with typos, collisions, and weak refactors.
+
+**Goal:** Keep context ergonomic while improving safety and discoverability.
+
+**Suggested changes:**
+- Introduce typed `ContextKey`.
+- Keep string keys only as a compatibility or convenience layer.
+- Encourage module-scoped or namespaced keys for plugins and larger projects.
+
+### 5. Make `SceneManager` less monolithic and more query-driven
+**Problem:** `SceneManager` currently owns many responsibilities and system registration is mostly driven by node type.
+
+**Goal:** Support more flexible matching and avoid turning `SceneManager` into another god object.
+
+**Suggested changes:**
+- Support multiple matching styles:
+ - by type
+ - by interface/capability
+ - by predicate/query
+ - by group/tag
+- Internally split responsibilities into collaborators such as:
+ - scene tree ownership
+ - path index
+ - group index
+ - system registry
+ - tick scheduler
+- Keep the current type-based route as the default, not the only route.
+
+### 6. Keep modularity architectural, not just Gradle-level
+**Problem:** module boundaries exist, but lower-level modules can still become too aware of higher-level concerns.
+
+**Goal:** Ensure modules stay independently reusable and backend-neutral where possible.
+
+**Suggested changes:**
+- Define strict dependency direction rules.
+- Split API modules from implementation modules where useful, such as `logging-api` vs concrete logging backend.
+- Keep `core` depending on small abstractions instead of concrete runtime details.
+- Regularly ask whether a module can be used outside a full Canopy app.
+
+### 7. Document the manual form of every convenience feature
+**Problem:** frameworks feel limiting when users cannot tell how to bypass the sugar.
+
+**Goal:** Make escape hatches official and discoverable.
+
+**Suggested changes:**
+- For every convenience API, document:
+ - the simple path
+ - the explicit/manual path
+ - when to use each
+ - extension points
+- Apply this to DSL tree construction, behaviors, manager lookup, context lookup, and scene operations.
+
+### 8. Prefer composition-first extension points
+**Problem:** too much extension pressure naturally flows into inheritance.
+
+**Goal:** Keep inheritance convenient, but make composition the stronger long-term extension model.
+
+**Suggested changes:**
+- Add extension surfaces like:
+ - lifecycle listeners
+ - node features/plugins
+ - scene hooks
+ - matcher/query strategies
+ - manager factories
+- Let subclassing remain the easiest route for common use, but not the only powerful one.
+
+### 9. Keep the core broad enough for headless and non-visual use
+**Problem:** some current abstractions still risk assuming a graphical 2D engine first.
+
+**Goal:** Make headless, simulation, tooling, and future UI layers feel native rather than secondary.
+
+**Suggested changes:**
+- Check whether core APIs assume graphics, LibGDX types, spatial nodes, or one specific loop model.
+- Move rendering- or 2D-specific assumptions above the lowest core layer.
+- Treat headless support as a design constraint that keeps the architecture honest.
+
+## Suggested priority order
+
+### Phase 1: clarity without major rewrites
+- Document lifecycle phases clearly.
+- Document manual forms for convenience APIs.
+- Add typed `ContextKey`.
+- Clarify which APIs are stable, experimental, or internal.
+- Explicitly frame globals as convenience rather than foundation.
+
+### Phase 2: reduce coupling
+- Introduce instance-based `ManagerContainer`.
+- Separate build / attach / activate operations.
+- Refactor `SceneManager` internally into collaborators.
+- Keep current APIs as wrappers to preserve ergonomics.
+
+### Phase 3: broaden the core
+- Introduce a smaller base node abstraction.
+- Move transforms / behavior / groups into optional capabilities.
+- Keep the current `Node` as the default higher-level node type.
+
+---
+
+# Canopy architecture review rubric
+
+Use this rubric when reviewing a subsystem, API, or feature.
+
+## A. Core abstraction quality
+Score each item from **0 to 2**.
+
+### 1. Generic enough
+- **0** = encodes one use case too narrowly
+- **1** = somewhat reusable
+- **2** = clearly usable across games, sims, tools, and headless flows
+
+### 2. Policy separated from mechanism
+- **0** = only one built-in way
+- **1** = some customization
+- **2** = reusable primitives with replaceable policies/defaults
+
+### 3. Low-level manual form exists
+- **0** = only DSL or implicit path
+- **1** = partial explicit API
+- **2** = complete explicit API under the convenience layer
+
+### 4. Easy to opt out of defaults
+- **0** = hard-wired
+- **1** = awkward to bypass
+- **2** = easy to bypass or replace
+
+## B. Extensibility
+
+### 5. Extension points are explicit
+- **0** = users must read internals to extend
+- **1** = some hooks are visible
+- **2** = stable, intentional, documented extension surfaces
+
+### 6. Composition works better than inheritance
+- **0** = inheritance required
+- **1** = mixed approach
+- **2** = composition-first, inheritance optional
+
+### 7. Weird use cases can work without forking
+- **0** = no
+- **1** = maybe, with hacks
+- **2** = yes, reasonably
+
+## C. Dependency and modularity health
+
+### 8. Independently adoptable
+- **0** = drags much of the engine with it
+- **1** = somewhat independent
+- **2** = easy to use standalone
+
+### 9. Dependency direction is clean
+- **0** = lower layers know too much about higher layers
+- **1** = some leakage
+- **2** = clean layering
+
+### 10. Global state is optional
+- **0** = mandatory
+- **1** = partially optional
+- **2** = clearly optional, with instance-scoped alternative
+
+## D. Runtime behavior
+
+### 11. Lifecycle is explicit and predictable
+- **0** = hidden or ambiguous
+- **1** = mostly understandable
+- **2** = well-defined and independently controllable
+
+### 12. Ordering is deterministic
+- **0** = surprising
+- **1** = mostly stable
+- **2** = explicit and documented
+
+### 13. Testable in isolation
+- **0** = painful
+- **1** = possible with setup tricks
+- **2** = easy to instantiate and test in isolation
+
+## E. Long-term maintainability
+
+### 14. Custom data is first-class
+- **0** = rigid
+- **1** = some escape hatches
+- **2** = natural support for user-defined metadata, services, and config
+
+### 15. Public API is intentionally bounded
+- **0** = internals leak everywhere
+- **1** = somewhat controlled
+- **2** = compact public surface with clear internal boundaries
+
+### 16. Avoids creating a god object
+- **0** = yes, strongly concentrated responsibility
+- **1** = maybe / somewhat overloaded
+- **2** = responsibilities are well-bounded
+
+## Score interpretation
+- **28β32**: very healthy
+- **22β27**: good, but watch rough edges
+- **16β21**: useful, but likely to become limiting
+- **0β15**: likely to create framework friction later
+
+## PR review template
+
+```md
+## Architecture review
+
+**Subsystem:**
+**Purpose:**
+
+### Scores
+- Generic enough:
+- Policy vs mechanism:
+- Manual form exists:
+- Opt-out possible:
+- Extension points clear:
+- Composition-first:
+- Weird use cases possible:
+- Independent adoption:
+- Dependency direction clean:
+- Global state optional:
+- Lifecycle explicit:
+- Ordering deterministic:
+- Testable in isolation:
+- Custom data support:
+- Public API bounded:
+- Avoids god object:
+
+**Total:** / 32
+
+### Main strengths
+-
+-
+
+### Main lock-in risks
+-
+-
+
+### Recommended next step
+-
+```
+
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 7bc1d26..0133baf 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,48 +1,118 @@
-
- Roadmap
+ Roadmap
-### In this page, you can find the **roadmap** for the development of **Canopy**.
+This page outlines the **development roadmap** for the Canopy Engine.
+
+The roadmap describes upcoming milestones and the goals of each stage of development.
+
+---
+
+# Next Milestone
+
+## π± Maiden Release β *First Flight*
+
+The **Maiden Release** will be the first public version of Canopy.
+
+Its goal is to deliver a **headless runtime** that runs entirely in the **terminal**, allowing the core engine architecture to be tested and validated before introducing graphical backends.
+
+This stage focuses on stabilizing the **core architecture** and gathering feedback from early users.
+
+---
+
+## Goals
+
+### Validate the Engine Architecture
+
+This release will test the fundamental design of the engine, including:
+
+- node-based scene architecture
+- reactive state systems
+- runtime processing systems
+
+Early feedback will help refine the architecture before additional features are introduced.
+
+---
+
+### Provide a Usable Prototype
+
+The engine should already be usable for building **simple terminal-based games or simulations**.
+
+This helps validate the developer experience and identify usability issues.
+
+---
+
+# Planned Features
+
+### Core Engine
+
+- Scene and node management
+- Declarative node DSL
+- Behavior system
+- Tree system execution
---
-## Next Milestones
+### Runtime Systems
+
+- Signal system for decoupled communication
+- Metrics system for performance tracking
-### **Maiden Release - First Flight** (We're here!)
+---
-This will be the first public release of **Canopy**. Its goal is to provide a **headless version** of the engine,
-capable of running the core features in the **terminal**. This will allow us to test the core architecture and features
-of the engine, and to gather important feedback.
+### Terminal Runtime
-Features to be implemented in this release include:
-- **Scene and node management**: the ability to create and manage scenes, and to define them in a declarative way using a node system.
-- **Signal system**: the ability to send and receive signals between different parts of the engine, allowing for a decoupled architecture.
-- **Metrics system**: the ability to track and log important metrics about the engine's performance and usage, which will be crucial for optimizing the engine and identifying bottlenecks.
+- Text rendering in the terminal
+- Styled text support (colors, formatting)
+- Keyboard input handling
+- Audio playback
+
+---
-- **Text rendering**: the ability to render text in the terminal. Text styles will be supported, allowing for different fonts, sizes, and colors.
-- **Keyboard input**: the ability to detect and respond to keyboard input.
-- **Audio playback**: the ability to play audio files in the terminal.
+# Tech Demo
-There are two main goals for this release:
-1. **Test the core architecture and log important metrics**: this release will allow us to test the core architecture of
-the engine, and to gather important feedback about its design and implementation. The metrics system will be crucial for
-identifying bottlenecks and optimizing the engine in future releases.
-2. **Release a ready-to-use version of the engine**: this release will provide a ready-to-use version of the engine,
-which can be used to create simple games and demos. This will allow us to gather feedback from developers and to identify
-areas for improvement in future releases.
+The planned demo for this version is a text-based **Ecosystem Simulation**
+The demo will demonstrate:
-#### **Tech demo**: A simple text-driven adventure game, showcasing the core features of the engine.
-#### **Estimated release date**: **TBA**.
-#### **[Release details](releases/0.1.0.md)**
+- scene composition
+- signals and event-driven logic
+- terminal rendering
+- gameplay interaction
---
+# Release Information
+
+| Item | Value |
+|-----|------|
+| Version | **0.1.0** |
+| Codename | *First Flight* |
+| Status | In development |
+| Estimated Release | **TBA** |
+
+More details can be found in the release notes:
+
+β‘ **[Release Details](releases/0.1.0.md)**
+
---
-Canopy 2026
+# Future Direction
+
+After the Maiden Release, development will focus on:
+- graphical rendering backends
+- improved tooling
+- expanded runtime systems
+- additional engine modules
+
+These goals will evolve as the architecture stabilizes and community feedback is collected.
+
+---
+
+
+ Canopy Roadmap β’ 2026
+
diff --git a/engine/app/app-core/build.gradle.kts b/engine/app/app-core/build.gradle.kts
index 2616f27..17eb5b5 100644
--- a/engine/app/app-core/build.gradle.kts
+++ b/engine/app/app-core/build.gradle.kts
@@ -6,8 +6,9 @@ plugins {
dependencies {
// Canopy deps
- implementation(projects.engine.core)
- implementation(projects.engine.logging)
+ api(projects.engine.core)
+ api(projects.engine.logging)
+ api(projects.engine.data.dataCore)
// Ktx
api(libs.ktx.app)
diff --git a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyApp.kt b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyApp.kt
index b22412b..fadea6d 100644
--- a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyApp.kt
+++ b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyApp.kt
@@ -9,87 +9,101 @@ import io.canopy.engine.core.CanopyBuildInfo
import io.canopy.engine.core.managers.InjectionManager
import io.canopy.engine.core.managers.ManagersRegistry
import io.canopy.engine.core.managers.SceneManager
-import io.canopy.engine.logging.api.LogContext
-import io.canopy.engine.logging.api.LogLevel
-import io.canopy.engine.logging.bootstrap.CanopyLogging
-import io.canopy.engine.logging.engine.EngineLogs
+import io.canopy.engine.data.core.assets.AssetsManager
+import io.canopy.engine.logging.CanopyLogging
+import io.canopy.engine.logging.EngineLogs
+import io.canopy.engine.logging.LogContext
+import io.canopy.engine.logging.LogLevel
import ktx.app.KtxGame
import ktx.async.KtxAsync
/**
- * Base App class - starting point of a Canopy App
+ * Base application class and primary entry point for a Canopy app.
+ *
+ * Responsibilities:
+ * - Bootstraps engine services (logging, async, managers, screens)
+ * - Provides lifecycle hooks (onCreate / onRender / onResize / onDispose)
+ * - Supports both blocking and async launch styles
+ * - Exposes a [CanopyAppHandle] so callers can request graceful exit or force close
+ *
+ * Backends:
+ * Subclasses implement [internalLaunch] to start a specific backend (e.g., LWJGL3).
+ * Some backends block until exit; others may return immediately.
*/
-abstract class CanopyApp protected constructor() : KtxGame() {
- /* ====================
- * App properties
- * ==================== */
+abstract class CanopyApp protected constructor(isGraphical: Boolean = true) :
+ KtxGame(clearScreen = isGraphical) {
+
+ /* ============================================================
+ * App state
+ * ============================================================ */
+
private val screenRegistry = CanopyScreenRegistry(this)
val sceneManager: SceneManager = SceneManager()
- /* App config */
private var _config: C? = null
protected val config: C get() = _config ?: defaultConfig()
- /* Holds track of frame count */
private var frame: Long = 0
- /* ========================
- * Lifecycle callbacks
- * ======================== */
+ /* ============================================================
+ * Lifecycle callbacks (user hooks)
+ * ============================================================ */
+
protected var onCreate: (CanopyApp) -> Unit = {}
protected var onRender: (CanopyApp) -> Unit = {}
protected var onResize: (CanopyApp, width: Int, height: Int) -> Unit = { _, _, _ -> }
protected var onDispose: (CanopyApp) -> Unit = {}
+
+ /**
+ * User log verbosity written to `user.log`.
+ */
protected var logLevel: LogLevel = LogLevel.DEBUG
- /* =============================================
- * Async helpers - allow async app handling
- * ============================================= */
+ /* ============================================================
+ * Async launch / app handle plumbing
+ * ============================================================ */
- // Countdown latches - to control when an app started and finished
- private val started = CountDownLatch(1)
- private val finished = CountDownLatch(1)
+ /**
+ * Signals:
+ * - startedLatch: boot completed (logging/managers/screens installed)
+ * - finishedLatch: application finished (dispose completed / thread ended)
+ */
+ private val startedLatch = CountDownLatch(1)
+ private val finishedLatch = CountDownLatch(1)
- // Refs to callbacks - used to 'install' the handler before running the app
+ /**
+ * Exit hooks provided by the backend once it is initialized.
+ * Until installed, the handle falls back to best-effort mechanisms.
+ */
private val backendExitRef = AtomicReference<(() -> Unit)?>(null)
private val backendForceRef = AtomicReference<(() -> Unit)?>(null)
- // References the launch thread
private val launchThreadRef = AtomicReference(null)
-
- // Reference errors thrown by the launch thread
private val launchErrorRef = AtomicReference(null)
- /*
- * App Handle - useful for forcing or scheduling app close
- */
val handle: CanopyAppHandle = ProxyAppHandle(
- finished = finished,
+ finished = finishedLatch,
onRequestExit = {
backendExitRef.get()?.invoke() ?: run {
- // if backend not installed yet, best-effort: interrupt launch thread (set by launchAsync)
+ // Backend not installed yet: best-effort exit by interrupting the launch thread.
launchThreadRef.get()?.interrupt()
}
},
onForceClose = {
backendForceRef.get()?.invoke() ?: run {
- // last resort; you can prefer waiting then halting
+ // Last resort: halt the JVM. Prefer graceful shutdown when possible.
Runtime.getRuntime().halt(0)
}
},
- onAwaitStarted = { timeout, timeUnit -> started.await(timeout, timeUnit) }
+ onAwaitStarted = { timeout, timeUnit -> startedLatch.await(timeout, timeUnit) }
)
- /* =========================================
- * LIFECYCLE OPERATIONS
- * =========================================
- */
+ /* ============================================================
+ * Engine lifecycle (KtxGame hooks)
+ * ============================================================ */
- /**
- * Called on app setup - similar to nodes' 'onReady' callbacks
- */
override fun create() {
- // Init logging FIRST (so startup logs are captured)
+ // 1) Logging first (captures the rest of startup)
val runId = CanopyLogging.defaultRunId()
val logDir = CanopyLogging.defaultBaseLogDir()
val engineVersion = CanopyBuildInfo.projectVersion
@@ -105,61 +119,54 @@ abstract class CanopyApp protected constructor() : KtxGame<
)
)
- LogContext.with(
- "backend" to (this::class.simpleName ?: "unknown")
- ) {
- EngineLogs.lifecycle.info(
- "backend" to (this::class.simpleName ?: "unknown")
- ) { "Booting Canopy..." }
+ // Provide backend identity via MDC for all logs produced during boot.
+ val backendName = this::class.simpleName ?: "unknown"
+ LogContext.with("backend" to backendName) {
+ // No need to also attach "backend" as per-call fields: it's already in MDC.
+ EngineLogs.lifecycle.info { "Booting Canopy..." }
- // Should be called before managers are registered just in case the user decides to do setup here
onCreate(this)
- // Allows async asset loading
KtxAsync.initiate()
- // Register global managers
ManagersRegistry.apply {
+InjectionManager()
+ +AssetsManager()
+sceneManager
}.setup()
- // Screens added on the screen registry are added here
screenRegistry.setup()
- // Countdown so main thread stops blocking after app boots
- started.countDown()
+ // Unblock async launch callers (handle.awaitStarted / launchAsync waiting).
+ startedLatch.countDown()
+
super.create()
- EngineLogs.lifecycle.info(
- "event" to "app.launch.init"
- ) { "Application started." }
+ EngineLogs.lifecycle.info("event" to "app.launch.init") { "Application started." }
}
}
- /**
- * Called on each game loop - equivalent to 'update'
- */
override fun render() {
frame++
LogContext.with("frame" to frame) {
+ EngineLogs.lifecycle.debug("frame" to frame) { "Rendering frame..." }
onRender(this)
+ EngineLogs.lifecycle.debug("rendering" to frame) { "Frame rendered." }
super.render()
}
}
- /**
- * Called on screen resize
- */
override fun resize(width: Int, height: Int) {
super.resize(width, height)
sceneManager.resize(width, height)
onResize(this, width, height)
+ EngineLogs.lifecycle.debug(
+ "event" to "app.resize",
+ "width" to width,
+ "height" to height
+ ) { "Screen resized." }
}
- /**
- * Called on app disposal
- */
override fun dispose() {
try {
EngineLogs.lifecycle.info("event" to "app.dispose") { "Disposing app" }
@@ -170,28 +177,29 @@ abstract class CanopyApp protected constructor() : KtxGame<
throw t
} finally {
onDispose(this)
- finished.countDown()
+ finishedLatch.countDown()
super.dispose()
}
}
- /**
- * Default config
- */
+ /* ============================================================
+ * Configuration + backend contract
+ * ============================================================ */
+
abstract fun defaultConfig(): C
/**
- * Backend-specific start. May block until the app exits (LWJGL3 does).
- * Returns a backend handle (maybe "post-exit" for blocking backends).
+ * Backend-specific launch implementation.
+ *
+ * Backend implementer checklist:
+ * - Call [installBackendHandle] once the backend can handle exit requests.
+ * - Ensure that backend shutdown triggers [dispose] (so teardown + session end logs happen).
+ * - If the backend blocks (common), this method may not return until exit.
+ * - If the backend is non-blocking, the method may return immediately.
*/
protected abstract fun internalLaunch(config: C, vararg args: String)
- /**
- * Fully synchronous "run": starts and blocks until the app exits.
- */
fun launchBlocking(vararg args: String) {
- // For non-blocking backends, this ensures the caller still blocks until done.
- // For blocking backends, join() will return immediately since launch() returns post-exit.
launchAsync(threadName = "canopy-app", *args).join()
}
@@ -199,7 +207,6 @@ abstract class CanopyApp protected constructor() : KtxGame<
internalLaunch(config, *args)
}
- /** Async launch returns immediately with [handle]. */
fun launchAsync(threadName: String = "canopy-app", vararg args: String): CanopyAppHandle {
val runnable = {
try {
@@ -208,35 +215,37 @@ abstract class CanopyApp protected constructor() : KtxGame<
launchErrorRef.set(t)
throw t
} finally {
- finished.countDown()
+ finishedLatch.countDown()
}
}
- val t = Thread(runnable, threadName).apply {
- isDaemon = false
- }
-
+ val t = Thread(runnable, threadName).apply { isDaemon = false }
launchThreadRef.set(t)
t.start()
- // Ensure thread is started so handle.requestExit() can at least interrupt it
- started.await()
+ // Wait until boot is complete so the handle can safely request exit.
+ startedLatch.await()
launchErrorRef.get()?.let { throw it }
return handle
}
+ /**
+ * Installs backend exit callbacks so external callers can stop the app cleanly.
+ *
+ * @param requestExit graceful shutdown request (close window / stop loop)
+ * @param forceClose optional hard shutdown; defaults to [requestExit] if omitted
+ */
protected fun installBackendHandle(requestExit: () -> Unit, forceClose: (() -> Unit)? = null) {
backendExitRef.set(requestExit)
backendForceRef.set(forceClose ?: requestExit)
}
- /** ===================================
- * Declarative builder helpers
- * =================================== */
+ /* ============================================================
+ * Declarative builder helpers (DSL-like)
+ * ============================================================ */
- /* Helper methods for building the app */
fun sceneManager(lambda: SceneManager.() -> Unit) {
sceneManager.apply(lambda)
}
@@ -264,6 +273,10 @@ abstract class CanopyApp protected constructor() : KtxGame<
fun screens(handler: CanopyScreenRegistry.() -> Unit) {
screenRegistry.registerSetupCallback(handler)
}
+
+ fun managers(handler: ManagersRegistry.() -> Unit) {
+ ManagersRegistry.apply(handler)
+ }
}
private class ProxyAppHandle(
diff --git a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyAppConfig.kt b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyAppConfig.kt
index 1a78cbc..3fcb24a 100644
--- a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyAppConfig.kt
+++ b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyAppConfig.kt
@@ -1,8 +1,26 @@
package io.canopy.engine.app.core
/**
- * Generic configuration for canopy backends
- * @param title
- * @param fps
+ * Base configuration for a Canopy application backend.
+ *
+ * This class defines common configuration shared across different
+ * platform backends (e.g. LWJGL, headless, future mobile/web backends).
+ *
+ * Backend implementations may extend this class to add additional
+ * platform-specific settings (window size, vsync, fullscreen, etc.).
+ *
+ * Example:
+ * ```
+ * class DesktopConfig(
+ * title: String = "My Game",
+ * fps: Int = 60,
+ * val width: Int = 1280,
+ * val height: Int = 720
+ * ) : CanopyAppConfig(title, fps)
+ * ```
+ *
+ * @param title Window or application title.
+ * @param fps Target frames per second. Backends may use this to
+ * configure the render loop or frame limiter.
*/
open class CanopyAppConfig(val title: String = "Canopy Game", val fps: Int = 60)
diff --git a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyAppHandle.kt b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyAppHandle.kt
index a7f2930..14d0a20 100644
--- a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyAppHandle.kt
+++ b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/CanopyAppHandle.kt
@@ -3,26 +3,104 @@ package io.canopy.engine.app.core
import java.util.concurrent.TimeUnit
/**
- * Base class for creating handles for closing asynchronous-launched apps
+ * 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 CanopyAppHandle(
+ /** Called when a graceful shutdown is requested. */
private val onRequestExit: () -> Unit,
+
+ /** Called when the app must be forcefully terminated. */
private val onForceClose: () -> Unit = onRequestExit,
+
+ /**
+ * 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)
}
diff --git a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/screen/CanopyScreen.kt b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/screen/CanopyScreen.kt
index 28bc91e..f1d84b5 100644
--- a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/screen/CanopyScreen.kt
+++ b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/screen/CanopyScreen.kt
@@ -5,31 +5,67 @@ import io.canopy.engine.core.managers.manager
import ktx.app.KtxScreen
/**
- * Base screen for a Canopy Game
+ * Base screen implementation for Canopy applications.
+ *
+ * This class extends [KtxScreen] and adds a small lifecycle convenience:
+ * a [setup] method that is guaranteed to run **only once**, the first time
+ * the screen becomes visible.
+ *
+ * This avoids common issues where `show()` may be called multiple times
+ * during a screen's lifetime.
+ *
+ * Typical usage:
+ *
+ * ```
+ * class MainMenuScreen : CanopyScreen() {
+ * override fun setup() {
+ * // Create UI, entities, etc.
+ * }
+ * }
+ * ```
+ *
+ * Scene integration:
+ * Each frame the global [SceneManager] is ticked automatically,
+ * allowing scene logic and systems to update without requiring
+ * explicit calls in every screen.
*/
abstract class CanopyScreen : KtxScreen {
- // Used as a way to override the default behavior of ktxScreens
+
+ /**
+ * Tracks whether [setup] has already been executed.
+ */
private var setupCalled = false
/**
- * Called once when the screen is first shown. Use this method to set up your screen's content, such as creating
- * entities, loading assets, etc.
+ * Called once when the screen is first shown.
+ *
+ * Use this method to initialize screen content such as:
+ * - creating entities
+ * - registering systems
+ * - loading assets
+ * - building UI
*/
open fun setup() {}
+ /**
+ * Called by LibGDX/KTX when the screen becomes active.
+ *
+ * We intercept this call to ensure [setup] executes only once.
+ */
override fun show() {
- // First render is called once
if (!setupCalled) {
setup()
setupCalled = true
- super.show()
- return
}
+
super.show()
}
/**
- * Called on each frame - equivalent to an onUpdate
+ * Called every frame.
+ *
+ * Delegates to the [SceneManager] so that scene systems and entities
+ * are updated automatically for the active screen.
*/
override fun render(delta: Float) {
super.render(delta)
diff --git a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/screen/CanopyScreenRegistry.kt b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/screen/CanopyScreenRegistry.kt
index 5f727d0..d19921b 100644
--- a/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/screen/CanopyScreenRegistry.kt
+++ b/engine/app/app-core/src/main/kotlin/io/canopy/engine/app/core/screen/CanopyScreenRegistry.kt
@@ -4,41 +4,100 @@ import kotlin.reflect.KClass
import io.canopy.engine.app.core.CanopyApp
/**
- * Used to allow for DSL-like syntax when registering and starting a screen
+ * DSL-style registry for managing screens during app bootstrap.
+ *
+ * This exists to provide a clean, declarative way for apps to register and
+ * select screens, typically from:
+ *
+ * ```
+ * screens {
+ * +MainMenuScreen()
+ * +GameScreen()
+ * start()
+ * }
+ * ```
+ *
+ * The registry collects a setup callback (the DSL block) and executes it during
+ * engine startup via [setup]. This ensures screens are registered at the right
+ * time (after managers are ready, before the first screen is shown).
*/
class CanopyScreenRegistry(val app: CanopyApp<*>) {
+
+ /**
+ * Captures the user's DSL block registered through `CanopyApp.screens { ... }`.
+ * It is executed once during app startup.
+ */
private var setupCallback: CanopyScreenRegistry.() -> Unit = {}
/**
- * Adds a new screen
+ * Registers a screen instance with the underlying app.
+ *
+ * Note:
+ * The screen's [CanopyScreen.setup] runs when it is shown for the first time,
+ * not at registration time.
*/
inline fun screen(screen: T) {
app.addScreen(screen)
}
/**
- * Sets a screen as main
+ * Sets the current screen by type.
+ *
+ * Example:
+ * ```
+ * start()
+ * ```
*/
inline fun start() {
app.setScreen()
}
/**
- * Registers and sets the screen as main
+ * Convenience: registers a screen instance and immediately starts it.
+ *
+ * Example:
+ * ```
+ * start(MainMenuScreen())
+ * ```
*/
inline fun start(screen: T) {
screen(screen)
start()
}
+ /**
+ * Called by [CanopyApp] to register the DSL block that will later be executed by [setup].
+ */
fun registerSetupCallback(callback: CanopyScreenRegistry.() -> Unit = {}) {
setupCallback = callback
}
+ /**
+ * Executes the previously registered DSL block.
+ *
+ * This is invoked during app startup (see CanopyApp.create()).
+ */
fun setup() = setupCallback()
- inline operator fun T.unaryPlus() = screen(this)
- inline operator fun KClass.unaryMinus() {
+ /* ------------------------------------------------------------
+ * DSL operators
+ * ------------------------------------------------------------ */
+
+ /**
+ * Adds a screen to the app registry:
+ *
+ * `+MainMenuScreen()`
+ */
+ operator fun CanopyScreen.unaryPlus() {
+ screen(this)
+ }
+
+ /**
+ * Removes a screen type from the app registry:
+ *
+ * `-MainMenuScreen::class`
+ */
+ operator fun KClass.unaryMinus() {
app.removeScreen(this.java)
}
}
diff --git a/engine/app/app-core/src/main/resources/libgdx128.png b/engine/app/app-core/src/main/resources/libgdx128.png
deleted file mode 100644
index f810616..0000000
Binary files a/engine/app/app-core/src/main/resources/libgdx128.png and /dev/null differ
diff --git a/engine/app/app-core/src/main/resources/libgdx16.png b/engine/app/app-core/src/main/resources/libgdx16.png
deleted file mode 100644
index a6b1327..0000000
Binary files a/engine/app/app-core/src/main/resources/libgdx16.png and /dev/null differ
diff --git a/engine/app/app-core/src/main/resources/libgdx32.png b/engine/app/app-core/src/main/resources/libgdx32.png
deleted file mode 100644
index 9447b39..0000000
Binary files a/engine/app/app-core/src/main/resources/libgdx32.png and /dev/null differ
diff --git a/engine/app/app-core/src/main/resources/libgdx64.png b/engine/app/app-core/src/main/resources/libgdx64.png
deleted file mode 100644
index 7513f3b..0000000
Binary files a/engine/app/app-core/src/main/resources/libgdx64.png and /dev/null differ
diff --git a/engine/app/app-headless/build.gradle.kts b/engine/app/app-headless/build.gradle.kts
index b697e3c..1ae98b3 100644
--- a/engine/app/app-headless/build.gradle.kts
+++ b/engine/app/app-headless/build.gradle.kts
@@ -5,11 +5,14 @@ plugins {
dependencies {
// Canopy deps
- implementation(projects.engine.app.appCore)
- implementation(projects.engine.logging)
+ api(projects.engine.app.appCore)
+ // implementation(projects.engine.logging)
// Gdx
- implementation(libs.gdx.backend.headless)
+ api(libs.gdx.backend.headless)
+ val gdxPlatform = libs.gdx.platform.get().module
+ val gdxVer = libs.versions.gdx.get()
+ api("$gdxPlatform:$gdxVer:natives-desktop")
// Logging
runtimeOnly(libs.logback.classic)
diff --git a/engine/app/app-headless/src/main/kotlin/io/canopy/engine/app/headless/TerminalCanopyApp.kt b/engine/app/app-headless/src/main/kotlin/io/canopy/engine/app/headless/TerminalCanopyApp.kt
index 5e0fffb..61713b3 100644
--- a/engine/app/app-headless/src/main/kotlin/io/canopy/engine/app/headless/TerminalCanopyApp.kt
+++ b/engine/app/app-headless/src/main/kotlin/io/canopy/engine/app/headless/TerminalCanopyApp.kt
@@ -5,48 +5,85 @@ import com.badlogic.gdx.backends.headless.HeadlessApplication
import com.badlogic.gdx.backends.headless.HeadlessApplicationConfiguration
import io.canopy.engine.app.core.CanopyApp
import io.canopy.engine.app.core.CanopyAppConfig
-import io.canopy.engine.logging.api.Logs
+import io.canopy.engine.logging.logger
/**
- * Simple headless terminal app version of a [CanopyApp]
+ * Headless (no window) Canopy application backend.
+ *
+ * Intended use cases:
+ * - Automated tests / CI
+ * - Server-side simulation
+ * - Tools that need the engine loop without graphics
+ *
+ * Notes about lifecycle:
+ * - The engine core owns boot/teardown ordering (logging, managers, screens).
+ * - This backend is responsible only for starting LibGDX's headless runtime
+ * and wiring shutdown hooks through [installBackendHandle].
*/
-class TerminalCanopyApp internal constructor() : CanopyApp() {
- private val log = Logs.get("canopy.app.terminal")
+class TerminalCanopyApp internal constructor() : CanopyApp(isGraphical = false) {
+
+ private val log = logger()
override fun defaultConfig(): CanopyAppConfig = CanopyAppConfig(
title = "Test Headless Canopy Game"
)
/**
- * Core owns sync/async + handle lifecycle.
- * This backend just starts the HeadlessApplication and installs exit hooks.
+ * Headless backend does not render graphics. The engine loop is still driven
+ * by LibGDX, but we intentionally skip the normal render path.
+ *
+ * (If you later want simulation updates here, you can call super.render()
+ * or invoke your own tick logic.)
+ */
+ override fun render() {}
+
+ /**
+ * Starts the LibGDX headless application and installs exit/force-close hooks.
+ *
+ * Important:
+ * - [HeadlessApplication] starts its loop on its own thread and returns immediately.
+ * - This method may therefore return quickly even though the app keeps running.
+ * - The core layer (CanopyApp) handles sync/async launch semantics via latches/handle.
*/
override fun internalLaunch(config: CanopyAppConfig, vararg args: String) {
+ log.info { "Starting headless backend" }
+
val headless = HeadlessApplication(this, HeadlessApplicationConfiguration())
// As soon as libGDX is alive, install how to exit this backend.
- // (Gdx.app should be available around now, but we keep a direct reference too.)
+ // We prefer using Gdx.app when available so we can schedule exit on the libGDX thread.
installBackendHandle(
requestExit = {
- // safest: schedule on the libGDX thread when available
val app = Gdx.app
- if (app != null) app.postRunnable { app.exit() } else headless.exit()
- },
- forceClose = {
- // Headless should exit cleanly; last resort still halts JVM.
- // You can choose to just call headless.exit() here if you prefer.
- try {
+ if (app != null) {
+ // Schedule exit on the libGDX thread to avoid threading edge-cases.
+ app.postRunnable { app.exit() }
+ } else {
+ // Fallback: use the direct HeadlessApplication reference.
headless.exit()
- } finally {
- // optional last resort:
- // Runtime.getRuntime().halt(0)
}
+ },
+ forceClose = {
+ // Headless should exit cleanly; this is here for callers that require a "hard" stop.
+ // If you ever see hangs in CI, you can consider adding Runtime.halt(0) as a last resort.
+ headless.exit()
}
)
- // HeadlessApplication constructor returns immediately; loop runs in its own thread.
- // So internalLaunch returns quickly here (good for sync launch()).
+ // HeadlessApplication constructor returns immediately; the loop runs in its own thread.
+ // internalLaunch returning quickly is expected.
}
}
+/**
+ * Convenience DSL entry point for building a headless app.
+ *
+ * Example:
+ * ```
+ * terminalApp {
+ * onCreate { ... }
+ * screens { ... }
+ * }.launchBlocking()
+ * ```
+ */
fun terminalApp(builder: TerminalCanopyApp.() -> Unit = {}): TerminalCanopyApp = TerminalCanopyApp().apply(builder)
diff --git a/engine/app/app-test/src/main/kotlin/io/canopy/engine/app/test/TestHeadlessCanopyApp.kt b/engine/app/app-test/src/main/kotlin/io/canopy/engine/app/test/TestHeadlessCanopyApp.kt
index 127536f..312889a 100644
--- a/engine/app/app-test/src/main/kotlin/io/canopy/engine/app/test/TestHeadlessCanopyApp.kt
+++ b/engine/app/app-test/src/main/kotlin/io/canopy/engine/app/test/TestHeadlessCanopyApp.kt
@@ -8,15 +8,29 @@ import io.canopy.engine.app.core.CanopyAppConfig
import io.canopy.engine.core.managers.ManagersRegistry
/**
- * Test headless version of [CanopyApp] used for testing
+ * Headless Canopy application backend intended for tests.
+ *
+ * Goals:
+ * - Deterministic behavior (avoid backend-driven side effects)
+ * - Isolation between tests (no leaking managers / global state)
+ * - A reliable shutdown mechanism for CI
+ *
+ * Differences from other backends:
+ * - Lifecycle methods like render/pause/resume are overridden to no-op to keep
+ * tests focused on explicit actions rather than frame-driven behavior.
+ * - Global manager state is cleared on construction to avoid cross-test contamination.
*/
class TestHeadlessCanopyApp internal constructor() : CanopyApp() {
init {
- ManagersRegistry.teardown() // Clear managers inserted by other tests
+ // Tests often run in the same JVM. If a previous test registered managers and did not
+ // fully tear down (or the order differed), state can leak across test cases.
+ // Clearing here makes each TestHeadlessCanopyApp start from a clean baseline.
+ ManagersRegistry.teardown()
}
- // Testkit: no-op lifecycle for deterministic tests
+ // Test backend: no-op lifecycle for deterministic tests.
+ // If a specific test needs ticking, it should drive it explicitly.
override fun render() {}
override fun pause() {}
override fun resume() {}
@@ -26,24 +40,52 @@ class TestHeadlessCanopyApp internal constructor() : CanopyApp(
)
/**
- * Core owns sync/async + handle lifecycle.
- * This backend only starts the HeadlessApplication and installs exit hooks.
+ * Starts the LibGDX headless runtime and installs exit hooks for [CanopyAppHandle].
+ *
+ * Core (CanopyApp) owns:
+ * - sync/async launch semantics
+ * - latches + handle lifecycle
+ * - boot order (logging/managers/screens)
+ *
+ * This backend only:
+ * - constructs the headless application
+ * - wires graceful/forced exit behavior
*/
override fun internalLaunch(config: CanopyAppConfig, vararg args: String) {
val headless = HeadlessApplication(this, HeadlessApplicationConfiguration())
installBackendHandle(
requestExit = {
+ // Prefer posting exit on the LibGDX thread when available.
val app = Gdx.app
if (app != null) app.postRunnable { app.exit() } else headless.exit()
},
forceClose = {
- // For tests, prefer a clean exit. Keep JVM halt out of testkit by default.
+ // For tests, prefer a clean exit. Avoid Runtime.halt in the testkit by default.
headless.exit()
}
)
+
+ // HeadlessApplication returns immediately; the loop runs on its own thread.
+ // Tests should use the CanopyAppHandle to await start/exit where needed.
}
}
+/**
+ * Convenience DSL entry point for building a test headless app.
+ *
+ * Example:
+ * ```
+ * val app = testHeadlessApp {
+ * onCreate { ... }
+ * screens { ... }
+ * }
+ *
+ * val handle = app.launchAsync()
+ * handle.awaitStarted(5, TimeUnit.SECONDS)
+ * handle.requestExit()
+ * handle.join()
+ * ```
+ */
fun testHeadlessApp(builder: TestHeadlessCanopyApp.() -> Unit = {}): TestHeadlessCanopyApp =
TestHeadlessCanopyApp().apply(builder)
diff --git a/engine/app/app-test/src/test/kotlin/io/canopy/engine/app/test/CanopyAppTests.kt b/engine/app/app-test/src/test/kotlin/io/canopy/engine/app/test/CanopyAppTests.kt
index df98b22..ed39e8b 100644
--- a/engine/app/app-test/src/test/kotlin/io/canopy/engine/app/test/CanopyAppTests.kt
+++ b/engine/app/app-test/src/test/kotlin/io/canopy/engine/app/test/CanopyAppTests.kt
@@ -1,6 +1,8 @@
package io.canopy.engine.app.test
import kotlin.test.Test
+import kotlin.test.assertTrue
+import java.util.concurrent.TimeUnit
import kotlinx.coroutines.runBlocking
class CanopyAppTests {
@@ -9,10 +11,20 @@ class CanopyAppTests {
fun `should launch async and close`() = runBlocking {
val handle = testHeadlessApp {}.launchAsync()
- // Wait until the app actually started (exit hooks installed)
- check(handle.awaitStarted(500)) { "App didn't start in time" }
+ // 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.forceClose()
- handle.join()
+ // 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)
+ if (!exited) {
+ handle.forceClose()
+ handle.join()
+ }
}
}
diff --git a/engine/app/app-test/src/test/kotlin/io/canopy/engine/app/test/CanopyScreenTests.kt b/engine/app/app-test/src/test/kotlin/io/canopy/engine/app/test/CanopyScreenTests.kt
index 22b171c..482d84c 100644
--- a/engine/app/app-test/src/test/kotlin/io/canopy/engine/app/test/CanopyScreenTests.kt
+++ b/engine/app/app-test/src/test/kotlin/io/canopy/engine/app/test/CanopyScreenTests.kt
@@ -2,12 +2,13 @@ package io.canopy.engine.app.test
import kotlin.test.Test
import kotlin.test.assertTrue
+import java.util.concurrent.TimeUnit
import io.canopy.engine.app.core.screen.CanopyScreen
class CanopyScreenTests {
@Test
- fun `screen test`() {
+ fun `screen setup should run when screen starts`() {
var screenWasCreated = false
val screen = object : CanopyScreen() {
@@ -16,12 +17,22 @@ class CanopyScreenTests {
}
}
- testHeadlessApp {
+ val handle = testHeadlessApp {
screens {
start(screen)
}
}.launchAsync()
- assertTrue { screenWasCreated }
+ // Wait until the app finished booting
+ assertTrue(
+ handle.awaitStarted(1, TimeUnit.SECONDS),
+ "App failed to start in time"
+ )
+
+ // Now setup() should have run
+ assertTrue(screenWasCreated)
+
+ handle.requestExit()
+ handle.join()
}
}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/CanopyBuildInfo.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/CanopyBuildInfo.kt
index d3f6995..deee6f5 100644
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/CanopyBuildInfo.kt
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/CanopyBuildInfo.kt
@@ -3,29 +3,68 @@ package io.canopy.engine.core
import java.util.jar.JarFile
/**
- * Holds information about the build, such as engine version
+ * Provides metadata about the engine build.
+ *
+ * Currently, exposes information from the JAR manifest, such as the engine version.
+ *
+ * Behavior depends on how the application is running:
+ *
+ * 1) Running from a packaged JAR
+ * - The manifest is available.
+ * - Attributes like "Project-Version" can be read.
+ *
+ * 2) Running from an IDE / classes directory
+ * - There is usually no manifest containing custom attributes.
+ * - In this case values will fall back to defaults (e.g. `"Unknown"`).
+ *
+ * This object is intentionally lazy so that:
+ * - manifest reading only happens if the information is actually requested
+ * - startup cost stays minimal.
*/
object CanopyBuildInfo {
+ /**
+ * Lazily loads the manifest attributes from the running JAR (if available).
+ *
+ * Steps:
+ * 1. Determine where this class was loaded from.
+ * 2. If the location is a `.jar`, open it.
+ * 3. Read the manifest's main attributes.
+ *
+ * If running from IDE/classes, this returns null.
+ */
private val attributes by lazy {
- // Where this class was loaded from (jar or classes dir)
+
+ // Location where this class was loaded from (JAR or classes directory).
val url = CanopyBuildInfo::class.java.protectionDomain.codeSource?.location
?: return@lazy null
val uri = url.toURI()
val path = uri.path
- // If running from a jar
+ // When running from a packaged JAR, read the manifest.
if (path.endsWith(".jar")) {
JarFile(path).use { jar ->
jar.manifest?.mainAttributes
}
} else {
- // Running from IDE/classes dir: there is often no manifest with your attributes
+ // When running from IDE/classes directory there is usually no manifest.
null
}
}
+ /**
+ * Engine version extracted from the JAR manifest.
+ *
+ * Expected manifest entry:
+ *
+ * ```
+ * Project-Version: x.y.z
+ * ```
+ *
+ * If the manifest is unavailable (e.g. running from IDE),
+ * `"Unknown"` is returned.
+ */
val projectVersion: String
get() = attributes?.getValue("Project-Version") ?: "Unknown"
}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/GameManager.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/GameManager.kt
index df68f6c..89398d5 100644
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/GameManager.kt
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/GameManager.kt
@@ -1,11 +1,40 @@
package io.canopy.engine.core.managers
+/**
+ * Global runtime state for the engine.
+ *
+ * Currently used to control the engine execution mode (Normal vs Debug).
+ * Systems and tools can check this to enable additional diagnostics,
+ * logging, or debug-only behaviors.
+ */
object GameManager {
+
+ /**
+ * Current execution mode of the engine.
+ *
+ * Defaults to [ExecutionMode.Normal].
+ */
var executionMode: ExecutionMode = ExecutionMode.Normal
- fun onDebugMode() = executionMode == ExecutionMode.Debug
+
+ /**
+ * Returns true if the engine is running in debug mode.
+ */
+ fun isDebugMode(): Boolean = executionMode == ExecutionMode.Debug
}
+/**
+ * Defines the runtime execution mode of the engine.
+ */
enum class ExecutionMode {
+
+ /** Standard runtime mode used by normal gameplay. */
Normal,
+
+ /**
+ * Debug mode used for development and testing.
+ *
+ * Systems may enable additional logging, debugging visuals,
+ * or validation checks when this mode is active.
+ */
Debug,
}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/InjectionManager.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/InjectionManager.kt
index db31488..d7c907c 100644
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/InjectionManager.kt
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/InjectionManager.kt
@@ -1,80 +1,145 @@
package io.canopy.engine.core.managers
import kotlin.reflect.KClass
-import io.canopy.engine.logging.engine.EngineLogs
+import io.canopy.engine.logging.EngineLogs
/**
- * Manages DI across the app
+ * Simple dependency registry used by the engine.
+ *
+ * This is intentionally lightweight: it stores provider functions keyed by type.
+ * It is closer to a "service locator" than a full DI container:
+ * - No constructor injection
+ * - No scopes
+ * - No graph resolution
+ *
+ * Providers are invoked on demand when [inject] is called. A provider may return:
+ * - a singleton instance (if it captures/stores one)
+ * - a new instance per call (factory behavior)
+ *
+ * Thread-safety:
+ * This implementation is not synchronized. It assumes registration happens during
+ * startup (single-threaded) and lookups happen in a controlled environment.
*/
class InjectionManager : Manager {
- // Store provider functions (weakly)
- private val dependenciesMap = mutableMapOf, () -> Any>()
+ /**
+ * Maps a type to a provider function.
+ *
+ * Note: despite the "weakly" comment, this is a strong reference map.
+ * Providers may still choose to return weak references internally if desired.
+ */
+ private val providers = mutableMapOf, () -> Any>()
private val log = EngineLogs.subsystem("di")
- // ===============================
- // DEPENDENCY INJECTION
- // ===============================
- fun registerInjectable(kClass: KClass, injectable: () -> T) {
+ /* ============================================================
+ * Registration
+ * ============================================================ */
+
+ /**
+ * Registers a provider for the given type.
+ *
+ * @throws IllegalArgumentException if a provider for [kClass] is already registered
+ */
+ fun registerInjectable(kClass: KClass, provider: () -> T) {
val typeName = kClass.qualifiedName ?: kClass.simpleName ?: "UnknownType"
- require(kClass !in dependenciesMap) {
+ require(kClass !in providers) {
"Injectable of type $typeName is already registered."
}
- dependenciesMap[kClass] = injectable
+ providers[kClass] = provider
log.info(
"event" to "di.register",
"type" to typeName,
- "size" to dependenciesMap.size
+ "size" to providers.size
) { "Registered injectable" }
}
- inline operator fun plusAssign(noinline injectable: () -> T) =
- registerInjectable(T::class, injectable)
-
+ /**
+ * DSL helper:
+ * ```
+ * manager() += { MyService() }
+ * ```
+ */
+ inline operator fun plusAssign(noinline provider: () -> T) =
+ registerInjectable(T::class, provider)
+
+ /* ============================================================
+ * Resolution
+ * ============================================================ */
+
+ /**
+ * Resolves an instance for the requested type by calling its provider.
+ *
+ * Logging:
+ * Resolution is typically frequent, so successful lookups are logged at DEBUG.
+ * Missing registrations and type mismatches are logged at ERROR.
+ *
+ * @throws IllegalStateException if the type is not registered or provider returns a wrong type
+ */
@Suppress("UNCHECKED_CAST")
fun inject(kClass: KClass): T {
val typeName = kClass.qualifiedName ?: kClass.simpleName ?: "UnknownType"
- // Debug level: injection happens a lot; info would spam
+ // Debug level: injection happens a lot; info would be noisy.
log.debug(
"event" to "di.inject",
"type" to typeName
) { "Resolving injectable" }
- val ref = dependenciesMap[kClass]
- ?: run {
- log.error(
- "event" to "di.missing",
- "type" to typeName,
- "registered" to dependenciesMap.size
- ) { "Injectable not registered" }
- throw IllegalStateException("Injectable of type $typeName wasn't registered.")
- }
-
- return ref() as? T
- ?: run {
- log.error(
- "event" to "di.type_mismatch",
- "type" to typeName
- ) { "Provider returned unexpected type" }
- throw IllegalStateException("Injectable of type $typeName was not injected (type mismatch).")
- }
+ val provider = providers[kClass] ?: run {
+ log.error(
+ "event" to "di.missing",
+ "type" to typeName,
+ "registered" to providers.size
+ ) { "Injectable not registered" }
+
+ throw IllegalStateException("Injectable of type $typeName wasn't registered.")
+ }
+
+ return provider() as? T ?: run {
+ log.error(
+ "event" to "di.type_mismatch",
+ "type" to typeName
+ ) { "Provider returned unexpected type" }
+
+ throw IllegalStateException("Injectable of type $typeName was not injected (type mismatch).")
+ }
}
+ /* ============================================================
+ * Manager lifecycle
+ * ============================================================ */
+
override fun setup() {
log.debug("event" to "di.setup") { "Setup" }
}
override fun teardown() {
- log.debug("event" to "di.teardown", "size" to dependenciesMap.size) { "Teardown" }
- dependenciesMap.clear()
+ log.debug("event" to "di.teardown", "size" to providers.size) { "Teardown" }
+ providers.clear()
}
}
+/* ------------------------------------------------------------------
+ * Convenience helpers
+ * ------------------------------------------------------------------ */
+
+/**
+ * Resolves an instance from the global [InjectionManager].
+ *
+ * Example:
+ * ```
+ * val assets = inject()
+ * ```
+ */
inline fun inject(): T = manager().inject(T::class)
+/**
+ * Lazily resolves an instance on first access.
+ *
+ * Useful for screens/systems where construction happens before managers are ready.
+ */
inline fun lazyInject() = lazy { inject() }
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/Manager.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/Manager.kt
index 16694ed..16bc072 100644
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/Manager.kt
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/Manager.kt
@@ -1,22 +1,34 @@
package io.canopy.engine.core.managers
/**
- * Manager interface defining setup and teardown methods for system managers.
- * Each manager implementing this interface should provide its own setup and teardown logic.
- * Each manager represents a distinct singleton in the game architecture, responsible for a specific aspect of the game's functionality.
+ * Base interface for engine managers.
+ *
+ * Managers represent global services responsible for a specific subsystem
+ * (e.g. scenes, assets, dependency injection).
+ *
+ * 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.
+ *
+ * Implementations may override these methods to allocate or release
+ * resources as needed.
*/
interface Manager {
- // Logger instance for logging messages related to the Manager
/**
- * Initializes the manager, setting up necessary resources or configurations.
- * This method is called when the manager is first created or activated.
+ * Called when the manager is initialized during application startup.
+ *
+ * Use this method to allocate resources, register services,
+ * or perform any initialization logic.
*/
fun setup() = Unit
/**
- * Cleans up resources or configurations used by the manager.
- * This method is called when the manager is no longer needed or is being deactivated.
+ * Called when the manager is being shut down.
+ *
+ * Use this method to release resources or reset internal state.
*/
fun teardown() = Unit
}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/ManagersRegistry.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/ManagersRegistry.kt
index a50fbc0..cf6e687 100644
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/ManagersRegistry.kt
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/ManagersRegistry.kt
@@ -1,44 +1,104 @@
package io.canopy.engine.core.managers
import kotlin.reflect.KClass
-import io.canopy.engine.logging.api.LogContext
-import io.canopy.engine.logging.engine.EngineLogs
+import io.canopy.engine.logging.EngineLogs
+import io.canopy.engine.logging.LogContext
/**
- * ManagersRegistry is responsible for managing a collection of Manager instances.
+ * Global registry responsible for storing and managing engine [Manager] instances.
+ *
+ * The registry provides:
+ * - Registration / lookup of managers by type
+ * - A standard lifecycle: [setup] and [teardown]
+ * - A small DSL via operator overloads (`+manager`, `-ManagerClass`)
+ *
+ * Typical boot flow:
+ * 1) Register managers (usually during app startup)
+ * 2) Call [setup] to initialize them
+ * 3) Call [teardown] on shutdown to release resources
+ *
+ * Notes:
+ * - This is a global singleton registry.
+ * - Registration order matters if managers depend on each other (setup runs in insertion order).
+ * - This implementation is not synchronized; it assumes boot happens on one thread.
*/
object ManagersRegistry {
- // Engine subsystem logger (consistent + routable)
+ /** Engine subsystem logger (consistent + routable). */
private val log = EngineLogs.managers
+ /**
+ * Registered managers keyed by their concrete KClass.
+ *
+ * Using a mutable map means insertion order is preserved (LinkedHashMap behavior),
+ * so setup/teardown run deterministically in registration order.
+ */
private val managers = mutableMapOf, Manager>()
+ /* ============================================================
+ * Registration API
+ * ============================================================ */
+
+ /**
+ * Registers a manager instance.
+ *
+ * @throws IllegalArgumentException if the manager type is already registered
+ */
fun register(manager: T) {
val key = manager::class
require(managers[key] == null) {
- // NOTE: require message should be cheap; we can just return a string
+ // Keep require message cheap (no heavy formatting).
"Manager ${key.simpleName} is already registered"
}
managers[key] = manager
- log.debug("manager" to key.simpleName) {
+ log.debug("event" to "managers.register", "manager" to key.simpleName) {
"Registered manager"
}
}
+
+ /**
+ * DSL helper:
+ * `+AssetsManager()`
+ */
inline operator fun T.unaryPlus() = register(this)
+ /**
+ * Unregisters a manager type (does not call teardown).
+ *
+ * Use this when you need to remove a manager from the registry,
+ * typically in tests or dynamic setups.
+ */
fun unregister(klass: KClass) {
- log.debug("manager" to klass.simpleName) { "Unregistered manager" }
+ log.debug("event" to "managers.unregister", "manager" to klass.simpleName) {
+ "Unregistered manager"
+ }
managers.remove(klass)
}
+
+ /**
+ * DSL helper:
+ * `-AssetsManager::class`
+ */
inline operator fun KClass.unaryMinus() = unregister(this)
+ /* ============================================================
+ * Lookup / inspection API
+ * ============================================================ */
+
+ /** Returns true if a manager of the given type is registered. */
fun has(clazz: KClass) = clazz in managers
+
+ /** Kotlin-friendly containment check: `if (FooManager::class in ManagersRegistry) ...` */
operator fun contains(clazz: KClass<*>) = clazz in managers
+ /**
+ * Retrieves the manager instance for [clazz].
+ *
+ * @throws IllegalStateException if the manager is not registered
+ */
@Suppress("UNCHECKED_CAST")
fun getManager(clazz: KClass): T = managers[clazz] as? T
?: throw IllegalStateException(
@@ -49,8 +109,17 @@ object ManagersRegistry {
""".trimIndent()
)
+ /* ============================================================
+ * Lifecycle API
+ * ============================================================ */
+
+ /**
+ * Initializes all registered managers by calling [Manager.setup].
+ *
+ * Each manager setup runs with its name in MDC (`manager=`) to make logs easier to filter.
+ */
fun setup() {
- log.info("registered" to managers.size) {
+ log.info("event" to "managers.setup", "registered" to managers.size) {
"Bootstrapping managers"
}
@@ -62,11 +131,18 @@ object ManagersRegistry {
}
}
- log.info { "Finished bootstrapping managers" }
+ log.info("event" to "managers.setup.done") { "Finished bootstrapping managers" }
}
+ /**
+ * Shuts down all registered managers by calling [Manager.teardown],
+ * then clears the registry.
+ *
+ * Note: teardown runs in registration order (same as setup).
+ * If you ever need reverse-order teardown for dependency reasons, this is the place to change it.
+ */
fun teardown() {
- log.info("registered" to managers.size) {
+ log.info("event" to "managers.teardown", "registered" to managers.size) {
"Tearing down managers"
}
@@ -79,18 +155,39 @@ object ManagersRegistry {
}
managers.clear()
- log.info { "Finished tearing down managers" }
+ log.info("event" to "managers.teardown.done") { "Finished tearing down managers" }
}
+ /**
+ * Runs [block] with a fresh manager set:
+ * - Tears down the current registry
+ * - Executes [block] (where callers usually register managers)
+ * - Bootstraps the newly registered managers
+ *
+ * This is primarily useful for tests that need isolation between scenarios.
+ */
fun withScope(block: ManagersRegistry.() -> Unit) {
- log.info { "Creating scoped Managers registry..." }
+ log.info("event" to "managers.scope") { "Creating scoped Managers registry..." }
teardown()
block()
setup()
- log.info { "Finished creating scoped Managers registry" }
+ log.info("event" to "managers.scope.done") { "Finished creating scoped Managers registry" }
}
}
+/* ------------------------------------------------------------------
+ * Convenience helpers
+ * ------------------------------------------------------------------ */
+
+/**
+ * Retrieves a manager instance from [ManagersRegistry].
+ *
+ * Example:
+ * `val assets = manager()`
+ */
inline fun manager(): T = ManagersRegistry.getManager(T::class)
+/**
+ * Lazy version of [manager], resolved on first access.
+ */
inline fun lazyManager() = lazy { manager() }
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt
index 2c47851..1543fb2 100644
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/managers/SceneManager.kt
@@ -2,25 +2,50 @@ package io.canopy.engine.core.managers
import kotlin.reflect.KClass
import com.badlogic.gdx.math.Vector2
-import io.canopy.engine.core.nodes.core.Node
-import io.canopy.engine.core.nodes.core.TreeSystem
-import io.canopy.engine.core.signals.asSignalVal
-import io.canopy.engine.core.signals.createSignal
-import io.canopy.engine.logging.api.LogContext
-import io.canopy.engine.logging.engine.EngineLogs
+import io.canopy.engine.core.nodes.Node
+import io.canopy.engine.core.nodes.TreeSystem
+import io.canopy.engine.core.reactive.asSignal
+import io.canopy.engine.core.reactive.event
+import io.canopy.engine.logging.EngineLogs
+import io.canopy.engine.logging.LogContext
/**
- * SceneManager is responsible for managing a scene tree, systems, groups, and active camera.
+ * Manages the active scene tree and drives update systems.
+ *
+ * Responsibilities:
+ * - Own the current scene root ([currScene]) and handle scene replacement
+ * - Maintain a flat lookup table of nodes by path (useful for queries/debugging)
+ * - Register/unregister nodes into [TreeSystem]s based on node type
+ * - Maintain named node groups for broadcasting operations (e.g. "enemies", "ui")
+ * - Drive the update loop via [tick] with deterministic phase ordering
+ *
+ * Update flow:
+ * - Physics ticks run at a fixed time step ([physicsStep]) using an accumulator
+ * - Frame ticks run every frame with variable delta
+ *
+ * NOTE:
+ * This class does not currently enforce thread-safety. Scene mutation is expected
+ * to happen on the main/game thread.
*/
class SceneManager(private var physicsStep: Float = 1f / 60f, private val block: SceneManager.() -> Unit = {}) :
Manager {
- // Use a dedicated subsystem logger for scene management (routable + consistent)
+ /** Dedicated subsystem logger (routable + consistent). */
private val log = EngineLogs.subsystem("scene")
+ /**
+ * Flat index of nodes keyed by their path.
+ * This is updated when scenes are registered/unregistered.
+ */
private val flatTree = mutableMapOf>()
companion object {
+ /**
+ * Thread-local pointer to the "current" SceneManager.
+ *
+ * This is typically used by builders/DSLs that want implicit access to the active
+ * SceneManager during construction.
+ */
internal val currentParent = ThreadLocal.withInitial { null }
}
@@ -28,43 +53,79 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
currentParent.set(this)
}
- // ===============================
- // SIGNALS
- // ===============================
+ /* ============================================================
+ * Signals / events
+ * ============================================================ */
- /** Emitted when the window or viewport is resized */
- val sceneSize = Vector2.Zero.asSignalVal()
- val onResize = createSignal()
+ /** Current scene size signal (e.g., for UI/layout systems). */
+ val sceneSize = Vector2.Zero.asSignal()
- /** Emitted when scene is replaced **/
- val onSceneReplaced = createSignal?>()
+ /** Emitted when the window/viewport is resized. */
+ val onResize = event()
+
+ /** Emitted after the scene root is replaced. Payload is the new root (or null). */
+ val onSceneReplaced = event?>()
+
+ /* ============================================================
+ * Scene state
+ * ============================================================ */
- // ===============================
- // SCENE STATE
- // ===============================
private var _currScene: Node<*>? = null
+
+ /**
+ * Active scene root. Assigning to this property replaces the scene and triggers:
+ * - exit/unregister on the previous scene subtree
+ * - register/build on the new scene subtree
+ * - [onSceneReplaced] emission
+ */
var currScene: Node<*>?
get() = _currScene
set(value) = replaceScene(value)
+ /** Accumulator used to determine when to run fixed-step physics ticks. */
private var physicsAccumulator = 0f
- // ===============================
- // SYSTEMS
- // ===============================
+ /* ============================================================
+ * Systems
+ * ============================================================ */
+
+ /**
+ * Systems grouped by update phase. Each list is kept sorted by system priority.
+ */
private val systems: MutableMap> = mutableMapOf()
+
+ /**
+ * Direct lookup by system class (useful for get/remove).
+ */
private val systemsByClass = mutableMapOf, TreeSystem>()
+ /**
+ * Systems indexed by node type they care about. Used for registering/unregistering nodes.
+ */
private val systemsByNodeTypes = mutableMapOf>, MutableList>()
- // ===============================
- // GROUPS
- // ===============================
+ /* ============================================================
+ * Groups
+ * ============================================================ */
+
+ /**
+ * Named groups of nodes.
+ * Useful for broadcasting operations without traversing the tree.
+ */
val groups = mutableMapOf>>()
- // ===============================
- // SCENE MANAGEMENT
- // ===============================
+ /* ============================================================
+ * Scene replacement
+ * ============================================================ */
+
+ /**
+ * Replaces the active scene.
+ *
+ * Order:
+ * 1) Exit + unregister old subtree
+ * 2) Swap pointer and emit [onSceneReplaced]
+ * 3) Register + build new subtree
+ */
private fun replaceScene(newScene: Node<*>?) {
val oldScene = _currScene
@@ -96,16 +157,22 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
}
}
+ /**
+ * Registers all nodes in [root] into:
+ * - [flatTree] lookup table
+ * - any systems that declared interest in the node's type
+ */
internal fun registerSubtree(root: Node<*>? = currScene) {
root ?: return
+
traverseNodes(root) { node ->
- // keep a flat lookup by name (assuming unique names; if not, revisit this key)
+ // Flat lookup by path (assumes node paths are unique within a scene).
flatTree[node.path] = node
- // Register node into systems interested in its type
+ // Register node into systems interested in its type.
systemsByNodeTypes[node::class]?.forEach { sys ->
LogContext.with(
- "scene" to (root.name),
+ "scene" to root.name,
"nodePath" to node.path,
"system" to sys::class.simpleName
) {
@@ -122,8 +189,14 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
) { "Subtree registered" }
}
+ /**
+ * Unregisters all nodes in [root] from:
+ * - [flatTree]
+ * - any systems that declared interest in the node's type
+ */
internal fun unregisterSubtree(root: Node<*>? = currScene) {
root ?: return
+
traverseNodes(root) { node ->
flatTree.remove(node.path)
@@ -146,21 +219,33 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
) { "Subtree unregistered" }
}
+ /**
+ * Depth-first traversal of the node tree.
+ * Used for register/unregister operations.
+ */
private fun traverseNodes(node: Node<*>, action: (Node<*>) -> Unit) {
action(node)
node.children.values.forEach { traverseNodes(it, action) }
}
- // ===============================
- // SYSTEM MANAGEMENT
- // ===============================
-
+ /* ============================================================
+ * System management
+ * ============================================================ */
+
+ /**
+ * Registers a [TreeSystem] into the manager.
+ *
+ * Also indexes the system by:
+ * - phase ([TreeSystem.phase]) and priority
+ * - required node types ([TreeSystem.requiredTypes]) for fast node registration
+ */
fun addSystem(system: T) {
require(!hasSystem(system::class)) {
"System ${system::class.simpleName} is already registered"
}
systemsByClass[system::class] = system
+
systems.getOrPut(system.phase) { mutableListOf() }.let { list ->
list += system
list.sortBy(TreeSystem::priority)
@@ -179,8 +264,12 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
) { "Registered system" }
}
+ /** DSL helper: `+MySystem()` */
inline operator fun T.unaryPlus() = addSystem(this)
+ /**
+ * Unregisters a system type and removes it from all internal indexes.
+ */
fun removeSystem(kClass: KClass) {
val systemName = kClass.simpleName ?: "UnknownSystem"
@@ -201,25 +290,26 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
) { "Unregistered system" }
}
+ /** DSL helper: `-MySystem::class` */
inline operator fun (KClass).unaryMinus() = removeSystem(this)
@Suppress("UNCHECKED_CAST")
fun getSystem(clazz: KClass): T = systemsByClass[clazz] as? T
?: throw IllegalStateException(
"""
- [SCENE MANAGER]
- The system ${clazz.simpleName} isn't registered
- To fix it: register it into a Scene Manager!
+ [SCENE MANAGER]
+ The system ${clazz.simpleName} isn't registered
+ To fix it: register it into a Scene Manager!
""".trimIndent()
)
fun hasSystem(clazz: KClass): Boolean = clazz in systemsByClass.keys
-
operator fun contains(clazz: KClass<*>) = clazz in systemsByClass
- // ===============================
- // GROUP MANAGEMENT
- // ===============================
+ /* ============================================================
+ * Group management
+ * ============================================================ */
+
fun addToGroup(group: String, node: Node<*>) {
groups.computeIfAbsent(group) { mutableListOf() }.add(node)
log.trace("event" to "group.add", "group" to group, "nodePath" to node.path) {
@@ -235,6 +325,10 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
}
}
+ /**
+ * Applies [callback] to all nodes in the group.
+ * Useful for "broadcast" operations without scanning the whole tree.
+ */
fun signalGroup(group: String, callback: (node: Node<*>) -> Unit) {
val groupNodes = groups[group] ?: error("Group $group does not exist")
LogContext.with("group" to group) {
@@ -243,9 +337,22 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
}
}
- // ===============================
- // TICK
- // ===============================
+ /* ============================================================
+ * Tick / update loop
+ * ============================================================ */
+
+ /**
+ * Drives the scene update loop.
+ *
+ * Order (per tick):
+ * - If a physics step is due:
+ * - PhysicsPre systems
+ * - nodePhysicsUpdate(physicsStep)
+ * - PhysicsPost systems
+ * - FramePre systems
+ * - nodeUpdate(delta)
+ * - FramePost systems
+ */
fun tick(delta: Float) {
val root = currScene ?: return
@@ -258,6 +365,7 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
if (physicsFrame) {
log.trace("event" to "tick.physics") { "Physics tick" }
+
systems[TreeSystem.UpdatePhase.PhysicsPre]?.forEach { sys ->
LogContext.with("system" to (sys::class.simpleName ?: "UnknownSystem"), "phase" to "PhysicsPre") {
sys.tick(physicsStep)
@@ -289,11 +397,18 @@ 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) {
onResize.emit(width, height)
log.debug("event" to "scene.resize", "width" to width, "height" to height) { "Resize" }
}
+ /**
+ * Fixed time-step accumulator.
+ * Returns true when we should run a physics step.
+ */
private fun isPhysicsFrame(delta: Float): Boolean {
physicsAccumulator += delta
if (physicsAccumulator >= physicsStep) {
@@ -303,12 +418,17 @@ class SceneManager(private var physicsStep: Float = 1f / 60f, private val block:
return false
}
- // ===============================
- // LIFECYCLE HOOKS
- // ===============================
+ /* ============================================================
+ * Manager lifecycle
+ * ============================================================ */
+
override fun setup() {
log.info("event" to "sceneManager.setup", "physicsStep" to physicsStep) { "Setup" }
+
+ // Allow callers to register systems, groups, initial scene, etc.
this.block()
+
+ // Notify systems that they've been registered with the scene manager.
systems.values.flatten().forEach(TreeSystem::onRegister)
}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Behavior.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Behavior.kt
new file mode 100644
index 0000000..e7d4d0c
--- /dev/null
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Behavior.kt
@@ -0,0 +1,171 @@
+package io.canopy.engine.core.nodes
+
+// ===============================
+// NODE BEHAVIOR BASE
+// ===============================
+
+/**
+ * Base class for behaviors that can be attached to a [Node].
+ *
+ * Behaviors allow node logic to be composed modularly without requiring
+ * subclassing of the node itself.
+ *
+ * Typical responsibilities of a behavior:
+ * - responding to node lifecycle events
+ * - running frame or physics updates
+ * - encapsulating reusable gameplay logic
+ *
+ * Example:
+ * ```
+ * class RotateBehavior(node: MyNode) : Behavior(node) {
+ * override fun onUpdate(delta: Float) {
+ * node?.rotation += 90f * delta
+ * }
+ * }
+ * ```
+ *
+ * @param N Type of the [Node] this behavior operates on.
+ * @property node Reference to the node the behavior is attached to.
+ * May be null if the behavior was created detached.
+ */
+abstract class Behavior>(protected open val node: N? = null) {
+
+ /** Secondary constructor allowing behaviors to be created without a node reference. */
+ constructor() : this(null)
+
+ // ===============================
+ // LIFECYCLE METHODS
+ // ===============================
+
+ /**
+ * Called when the node enters the scene tree.
+ *
+ * At this point the node has a parent and exists within the tree structure,
+ * but children may not yet be fully initialized.
+ */
+ open fun onEnterTree() = Unit
+
+ /**
+ * Called when the node and all of its children have completed initialization.
+ *
+ * This is typically where behaviors should perform setup that depends on
+ * the full subtree being available.
+ */
+ open fun onReady() = Unit
+
+ /**
+ * Called when the node exits the scene tree.
+ *
+ * Use this to release resources or unregister listeners.
+ */
+ open fun onExitTree() = Unit
+
+ // ===============================
+ // UPDATES
+ // ===============================
+
+ /**
+ * Called every frame.
+ *
+ * Intended for general gameplay logic, animations, and rendering-related updates.
+ */
+ open fun onUpdate(delta: Float) = Unit
+
+ /**
+ * Called on each physics tick.
+ *
+ * Physics ticks run at a fixed step (defined by the SceneManager).
+ * Use this for deterministic physics calculations.
+ */
+ open fun onPhysicsUpdate(delta: Float) = Unit
+}
+
+// ===============================
+// LAMBDA BEHAVIOR HELPERS
+// ===============================
+
+/**
+ * Convenience DSL for attaching a behavior using lambdas instead of creating
+ * a subclass of [Behavior].
+ *
+ * Example:
+ * ```
+ * node.behavior(
+ * onEnterTree = { println("Node entered the tree!") },
+ * onUpdate = { delta -> println("Updating: $delta") }
+ * )
+ * ```
+ *
+ * @param onEnterTree Called when the node enters the scene tree
+ * @param onReady Called after the node and its children are fully initialized
+ * @param onExitTree Called when the node exits the scene tree
+ * @param onUpdate Called every frame
+ * @param onPhysicsUpdate Called on physics tick
+ */
+fun > N.behavior(
+ onEnterTree: N.() -> Unit = {},
+ onReady: N.() -> Unit = {},
+ onExitTree: N.() -> Unit = {},
+ onUpdate: N.(delta: Float) -> Unit = {},
+ onPhysicsUpdate: N.(delta: Float) -> Unit = {},
+) {
+ behavior = createBehavior(onEnterTree, onReady, onExitTree, onUpdate, onPhysicsUpdate)()
+}
+
+/**
+ * Attaches a behavior created by a builder function.
+ *
+ * Example:
+ * ```
+ * node.attachBehavior { MyCustomBehavior(it) }
+ * ```
+ */
+fun > N.attachBehavior(builder: (node: N) -> Behavior) {
+ behavior = builder(this)
+}
+
+/**
+ * DSL operator allowing behavior attachment with `+=`.
+ *
+ * Example:
+ * ```
+ * node += { MyBehavior(it) }
+ * ```
+ */
+operator fun > N.plusAssign(builder: (node: N) -> Behavior) = attachBehavior(builder)
+
+/**
+ * Factory function that creates a behavior implementation backed by lambdas.
+ *
+ * Internally used by the [behavior] DSL helper.
+ */
+fun > createBehavior(
+ onEnterTree: N.() -> Unit = {},
+ onReady: N.() -> Unit = {},
+ onExitTree: N.() -> Unit = {},
+ onUpdate: N.(delta: Float) -> Unit = {},
+ onPhysicsUpdate: N.(delta: Float) -> Unit = {},
+) = { node: N ->
+ object : Behavior(node) {
+
+ override fun onEnterTree() {
+ onEnterTree(node)
+ }
+
+ override fun onReady() {
+ onReady(node)
+ }
+
+ override fun onExitTree() {
+ onExitTree(node)
+ }
+
+ override fun onUpdate(delta: Float) {
+ onUpdate(node, delta)
+ }
+
+ override fun onPhysicsUpdate(delta: Float) {
+ onPhysicsUpdate(node, delta)
+ }
+ }
+}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/CanopyDsl.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/CanopyDsl.kt
new file mode 100644
index 0000000..7745b4a
--- /dev/null
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/CanopyDsl.kt
@@ -0,0 +1,4 @@
+package io.canopy.engine.core.nodes
+
+@DslMarker
+annotation class CanopyDsl
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt
new file mode 100644
index 0000000..86ea83f
--- /dev/null
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt
@@ -0,0 +1,583 @@
+package io.canopy.engine.core.nodes
+
+import kotlin.reflect.KClass
+import io.canopy.engine.core.managers.SceneManager
+import io.canopy.engine.core.managers.lazyManager
+import io.canopy.engine.core.managers.manager
+import io.canopy.engine.core.reactive.Context
+import io.canopy.engine.logging.EngineLogs
+import io.canopy.engine.logging.LogContext
+
+/**
+ * Base node class for a 2D scene graph.
+ *
+ * Design overview:
+ * - Nodes form a tree (parent/children) and expose a small DSL for building that tree.
+ * - Nodes can optionally have a [Behavior] attached (script-like logic).
+ * - The [SceneManager] uses the node tree to:
+ * - drive lifecycle callbacks (enter/ready/exit)
+ * - run updates (frame + physics)
+ * - register nodes into systems and groups
+ *
+ * Construction vs initialization:
+ * - `init { ... }` attaches this node to the current DSL parent (if any).
+ * - `nodeReady()` runs `create()` and the user-provided DSL [block] exactly once
+ * to build children and configure the node.
+ *
+ * Generic type parameter:
+ * - `N : Node` enables DSL blocks to have the concrete node type as receiver.
+ */
+@CanopyDsl
+@Suppress("UNCHECKED_CAST")
+abstract class Node> protected constructor(
+ /** Node name (expected to be unique among siblings). */
+ name: String,
+ /**
+ * Node DSL block used to configure/build the node subtree.
+ * Executed once during [nodeReady].
+ */
+ private val block: N.() -> Unit = {},
+) {
+
+ /* ============================================================
+ * Identity
+ * ============================================================ */
+
+ private var _name = name
+
+ /**
+ * Node name. Renaming updates:
+ * - the parentβs children map key
+ * - this nodeβs [path] and all descendant paths
+ */
+ var name
+ get() = _name
+ set(value) = rename(value)
+
+ /**
+ * Local group memberships. Groups are also mirrored into the [SceneManager] registry
+ * when the node enters the tree.
+ */
+ val groups: MutableList = mutableListOf()
+
+ /** Stable engine logger for node operations (routable to engine logs). */
+ private val log = EngineLogs.node
+
+ /** Scene manager instance (resolved lazily from ManagersRegistry). */
+ protected val sceneManager: SceneManager by lazyManager()
+
+ /** Prefab nodes do not run lifecycle automatically when attached. */
+ private var isPrefab: Boolean = false
+
+ /** Optional behavior instance attached to this node. */
+ internal var behavior: Behavior? = null
+
+ /* ============================================================
+ * Tree structure
+ * ============================================================ */
+
+ private var _parent: Node<*>? = null
+ val parent get() = _parent
+
+ private val _children: MutableMap> = mutableMapOf()
+ val children: Map> get() = _children
+
+ /**
+ * Full path from the tree root.
+ *
+ * Format: `/Root/Player/Weapon`
+ *
+ * Notes:
+ * - Root nodes have paths like `/RootName`
+ * - Paths are recomputed when:
+ * - a node is attached/detached
+ * - a node is renamed
+ */
+ private var _path: String = name
+ val path: String get() = _path
+
+ /* ============================================================
+ * DSL support
+ * ============================================================ */
+
+ companion object {
+ /**
+ * DSL builder state: the "current parent" node.
+ *
+ * During `nodeReady()`, this is temporarily set to the node being built so that
+ * children constructed in the DSL `block { ... }` automatically attach to it.
+ */
+ private val currentParent = ThreadLocal.withInitial?> { null }
+ }
+
+ init {
+ // If we are inside a DSL build block, auto-attach to the current parent.
+ // This is intentionally done in init to allow nested construction:
+ //
+ // parent.nodeReady() sets currentParent = parent
+ // child init reads it and attaches itself to parent
+ currentParent.get()?.addChildInternal(this)
+ }
+
+ /* ============================================================
+ * Child management
+ * ============================================================ */
+
+ /**
+ * Internal attach without lifecycle calls.
+ *
+ * Used by:
+ * - DSL construction (children attach during init)
+ * - runtime attach via [addChild] (then lifecycle is applied unless prefab)
+ */
+ private fun addChildInternal(child: Node<*>) {
+ check(child.name !in children) {
+ "Child with name '${child.name}' already exists under '${this.name}'"
+ }
+
+ _children[child.name] = child
+ child._parent = this
+ child.recomputePathRecursively()
+
+ LogContext.with("nodePath" to this.path, "childPath" to child.path) {
+ log.debug(
+ "event" to "node.add_child_internal",
+ "parent" to this@Node.name,
+ "child" to child.name
+ ) { "Attached child" }
+ }
+
+ // Register nodes into systems/groups indices maintained by SceneManager.
+ sceneManager.registerSubtree(child)
+ }
+
+ /**
+ * Attaches a child node at runtime and runs its lifecycle (unless it is a prefab).
+ *
+ * Lifecycle order for the attached subtree:
+ * - enterTree
+ * - ready
+ */
+ fun addChild(child: Node<*>) {
+ check(child.parent == null) { "Node '${child.name}' already has a parent!" }
+
+ addChildInternal(child)
+
+ if (child.isPrefab) {
+ LogContext.with("nodePath" to child.path) {
+ log.trace("event" to "node.add_child.prefab") { "Child is prefab; skipping lifecycle" }
+ }
+ return
+ }
+
+ LogContext.with("nodePath" to child.path) {
+ log.trace("event" to "node.lifecycle.enter_tree") { "enterTree()" }
+ child.nodeEnterTree()
+ log.trace("event" to "node.lifecycle.ready") { "ready()" }
+ child.nodeReady()
+ }
+ }
+
+ /** DSL: `+childNode` inside a node scope. */
+ operator fun Node<*>.unaryPlus() = addChild(this)
+
+ /** DSL: `node += child` */
+ operator fun plusAssign(child: Node<*>) = addChild(child)
+
+ /**
+ * Removes a child node and runs teardown lifecycle.
+ *
+ * Order:
+ * - exitTree on subtree
+ * - detach from parent
+ * - unregister subtree from SceneManager
+ */
+ fun removeChild(child: Node<*>) {
+ check(child.parent == this) { "Node '${child.name}' is not a child of '$name'!" }
+
+ LogContext.with("nodePath" to this.path, "childPath" to child.path) {
+ log.debug("event" to "node.remove_child") { "Removing child" }
+ }
+
+ LogContext.with("nodePath" to child.path) {
+ log.trace("event" to "node.lifecycle.exit_tree") { "exitTree()" }
+ child.nodeExitTree()
+ }
+
+ _children.remove(child.name)
+ child._parent = null
+ child.recomputePathRecursively()
+
+ sceneManager.unregisterSubtree(child)
+
+ // Remove remaining descendants by detaching them from the child.
+ child.children.values.toList().forEach { child.removeChild(it) }
+ }
+
+ /** DSL: `-childNode` */
+ operator fun Node<*>.unaryMinus() = removeChild(this)
+
+ /** DSL: `node -= child` */
+ operator fun minusAssign(child: Node<*>) = removeChild(child)
+
+ /** Removes a child node by path. */
+ fun removeChild(path: String) {
+ val child = getNode(path)
+ removeChild(child)
+ }
+
+ /* ============================================================
+ * Node lookup
+ * ============================================================ */
+
+ /**
+ * Resolves a node by path.
+ *
+ * Path rules:
+ * - "$/..." resolves from the current scene root
+ * - "./..." resolves from this node
+ * - "../" goes to parent (skipping [ContextScopeNode] wrappers)
+ * - Paths may omit ContextScopeNode segments; lookup searches through context wrappers
+ * to make DSL context blocks transparent.
+ *
+ * Examples:
+ * - `$/Player/Weapon`
+ * - `./UI/HUD`
+ * - `../Camera`
+ */
+ @Suppress("UNCHECKED_CAST")
+ fun > getNode(path: String): T {
+ val parts = path.split("/")
+
+ var current: Node<*>? = when (parts.firstOrNull()) {
+ "$" -> sceneManager.currScene
+ "", "." -> this
+ else -> this
+ }
+
+ val searchParts =
+ if (parts.firstOrNull() in listOf("$", ".") || path.startsWith("/")) {
+ parts.drop(1)
+ } else {
+ parts
+ }
+
+ // Walk up skipping ContextScopeNode wrappers (they are implementation details).
+ fun Node<*>.visibleParent(): Node<*>? {
+ var p = this.parent
+ while (p is Context) p = p.parent
+ return p
+ }
+
+ // Find child, treating ContextScopeNode as transparent containers.
+ fun Node<*>.findChildSkippingContext(name: String): Node<*>? {
+ // 1) direct child wins
+ this.children[name]?.let { return it }
+
+ // 2) otherwise, search one level into context scopes
+ for (c in this.children.values) {
+ if (c is Context) {
+ c.children[name]?.let { return it }
+
+ // 3) also allow nested context scopes (common with nested context blocks)
+ for (nested in c.children.values) {
+ if (nested is Context) {
+ nested.children[name]?.let { return it }
+ }
+ }
+ }
+ }
+
+ return null
+ }
+
+ for (part in searchParts) {
+ when (part) {
+ "", "." -> Unit
+ ".." -> {
+ current = current?.visibleParent()
+ ?: throw IllegalArgumentException("No parent for path: $path")
+ }
+ else -> {
+ val cur = current ?: throw IllegalArgumentException("Null node while resolving path: $path")
+ val next = cur.findChildSkippingContext(part)
+ ?: throw IllegalArgumentException("No child '$part' under '${cur.name}' for path '$path'")
+ current = next
+ }
+ }
+ }
+
+ return current as T
+ }
+
+ /** Kotlin shorthand: `node["Player/Weapon"]` */
+ inline operator fun > get(path: String): T = getNode(path)
+
+ /* ============================================================
+ * Prefab / instancing
+ * ============================================================ */
+
+ /**
+ * Marks this node as a prefab.
+ *
+ * Prefabs are attachable as children but do not automatically run lifecycle.
+ * Intended for templates that are instantiated/activated later.
+ */
+ fun asPrefab(): N {
+ isPrefab = true
+ LogContext.with("nodePath" to path) {
+ log.trace("event" to "node.prefab") { "Marked as prefab" }
+ }
+ return this as N
+ }
+
+ /** Removes this node from its parent. */
+ fun queueFree() {
+ LogContext.with("nodePath" to path) {
+ log.debug("event" to "node.queue_free") { "Queue free" }
+ }
+ parent?.removeChild(this)
+ }
+
+ /**
+ * Moves an existing child from this parent to [newParent].
+ */
+ fun reparent(child: Node<*>, newParent: Node<*>) {
+ LogContext.with("childPath" to child.path, "fromParent" to this.path, "toParent" to newParent.path) {
+ log.info("event" to "node.reparent") { "Reparenting child" }
+ }
+ removeChild(child)
+ newParent.addChild(child)
+ }
+
+ fun hasChildType(type: KClass>) = children.values.any { it::class == type }
+
+ /* ============================================================
+ * Group management (mirrors into SceneManager)
+ * ============================================================ */
+
+ fun inGroup(group: String) = group in groups
+
+ fun addGroup(group: String) = groups.add(group).also {
+ LogContext.with("nodePath" to path, "group" to group) {
+ log.trace("event" to "node.group.add") { "Add group" }
+ }
+ sceneManager.addToGroup(group, this)
+ }
+
+ fun removeGroup(group: String) = groups.remove(group).also {
+ LogContext.with("nodePath" to path, "group" to group) {
+ log.trace("event" to "node.group.remove") { "Remove group" }
+ }
+ sceneManager.removeFromGroup(group, this)
+ }
+
+ /* ============================================================
+ * Tree building
+ * ============================================================ */
+
+ /**
+ * Builds this node as a root/subtree:
+ * - enterTree
+ * - ready (which executes create() + DSL block once)
+ */
+ fun buildTree() {
+ LogContext.with("nodePath" to path) {
+ log.debug("event" to "node.build_tree") { "Building tree" }
+ }
+ nodeEnterTree()
+ nodeReady()
+ }
+
+ /* ============================================================
+ * Lifecycle hooks
+ * ============================================================ */
+
+ /**
+ * Override for predefined node configuration.
+ *
+ * Example uses:
+ * - internal child structure
+ * - default components/behavior
+ * - setting initial transforms
+ */
+ open fun create() {}
+
+ private var built = false
+
+ /**
+ * Called when the node and its subtree should finish initialization.
+ *
+ * This method:
+ * - runs `create()` + the DSL [block] once (guarded by [built])
+ * - then recurses into children (so the entire subtree becomes ready)
+ * - then fires behavior.onReady()
+ */
+ open fun nodeReady() {
+ LogContext.with("nodePath" to path) {
+ log.trace("event" to "node.ready") { "nodeReady()" }
+ }
+
+ // Children were attached during their init; now recurse.
+ children.values.forEach { it.nodeReady() }
+ behavior?.let { runBehavior("ready") { it.onReady() } }
+ }
+
+ /**
+ * Called when the node enters the tree.
+ *
+ * Order:
+ * - register groups in SceneManager
+ * - behavior.onEnterTree()
+ * - recurse into children
+ */
+ open fun nodeEnterTree() {
+ LogContext.with("nodePath" to path) {
+ log.trace("event" to "node.enter_tree") { "nodeEnterTree()" }
+ }
+
+ // Avoid executing DSL/build twice (e.g., if nodeReady is triggered again).
+ if (built) return
+ built = true
+
+ // Build subtree via DSL after full construction.
+ val oldParent = currentParent.get()
+ currentParent.set(this)
+
+ try {
+ create()
+ block(this as N)
+ } finally {
+ currentParent.set(oldParent)
+ LogContext.with("nodePath" to path) {
+ log.trace("event" to "node.constructed") { "Node constructed" }
+ }
+ }
+
+ groups.forEach { sceneManager.addToGroup(it, this) }
+ behavior?.let { runBehavior("enter_tree") { it.onEnterTree() } }
+ children.values.forEach { it.nodeEnterTree() }
+ }
+
+ /**
+ * Called when the node exits the tree.
+ *
+ * Order:
+ * - recurse into children
+ * - behavior.onExitTree()
+ */
+ open fun nodeExitTree() {
+ LogContext.with("nodePath" to path) {
+ log.trace("event" to "node.exit_tree") { "nodeExitTree()" }
+ }
+ children.values.forEach { it.nodeExitTree() }
+ behavior?.let { runBehavior("exit_tree") { it.onExitTree() } }
+ }
+
+ /* ============================================================
+ * Updates
+ * ============================================================ */
+
+ open fun nodeUpdate(delta: Float) {
+ LogContext.with("nodePath" to path, "delta" to delta) {
+ log.trace("event" to "node.update") { "nodeUpdate()" }
+ }
+ children.values.forEach { it.nodeUpdate(delta) }
+ behavior?.let { runBehavior("update") { it.onUpdate(delta) } }
+ }
+
+ open fun nodePhysicsUpdate(delta: Float) {
+ LogContext.with("nodePath" to path, "delta" to delta) {
+ log.trace("event" to "node.physics_update") { "nodePhysicsUpdate()" }
+ }
+ children.values.forEach { it.nodePhysicsUpdate(delta) }
+ behavior?.let { runBehavior("physics_update") { it.onPhysicsUpdate(delta) } }
+ }
+
+ /* ============================================================
+ * Internals
+ * ============================================================ */
+
+ /**
+ * Executes a behavior callback and logs/rethrows exceptions with useful context.
+ * This is intentionally fail-fast: behavior errors should be surfaced quickly.
+ */
+ private inline fun runBehavior(phase: String, delta: Float? = null, block: () -> Unit) {
+ try {
+ block()
+ } catch (t: Throwable) {
+ val fields = buildMap {
+ put("event", "behavior.error")
+ put("phase", phase)
+ put("nodePath", path)
+ put("behavior", behavior?.javaClass?.name)
+ if (delta != null) put("delta", delta)
+ }.map { Pair(it.key, it.value) }
+
+ EngineLogs.node.error(t = t, *fields.toTypedArray()) { "Behavior threw during $phase" }
+ throw t
+ }
+ }
+
+ /** Recomputes this node path and all descendant paths. */
+ private fun recomputePathRecursively() {
+ _path = parent?.let { "${it.path}/$name" } ?: "/$name"
+ _children.values.forEach { it.recomputePathRecursively() }
+ }
+
+ /**
+ * Renames this node and updates the parent index + paths.
+ */
+ private fun rename(newName: String) {
+ if (newName == name) return
+
+ val p = parent
+ if (p != null) {
+ require(!p._children.containsKey(newName)) {
+ "Sibling with name '$newName' already exists under parent '${p.path}'."
+ }
+
+ p._children.remove(name)
+ p._children[newName] = this
+ }
+
+ _name = newName
+ recomputePathRecursively()
+ }
+
+ /* ============================================================
+ * DSL helpers
+ * ============================================================ */
+
+ infix fun child(node: Node<*>) = addChild(node)
+
+ fun groups(vararg groups: String) = apply { groups.forEach { addGroup(it) } }
+
+ fun > patch(path: String, handler: T.() -> Unit) = getNode(path).apply(handler)
+
+ /* ------------------------------------------------------------------
+ * Top-level DSL helpers
+ * ------------------------------------------------------------------ */
+
+ /** `parent + child` attaches [child] to [parent] and returns [parent]. */
+ operator fun Node<*>.plus(node: Node<*>): Node<*> {
+ addChild(node)
+ return this
+ }
+
+ /**
+ * Sets this node as the active scene root in the global [SceneManager].
+ *
+ * This triggers SceneManager scene replacement logic (unregister old scene, register new scene).
+ */
+ fun asSceneRoot(): Node<*> {
+ val sceneManager = manager()
+ sceneManager.currScene = this
+
+ LogContext.with("nodePath" to this.path) {
+ EngineLogs.subsystem("scene").info("event" to "scene.set_root") { "Set as scene root" }
+ }
+
+ return this
+ }
+}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Node2D.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Node2D.kt
new file mode 100644
index 0000000..5916686
--- /dev/null
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Node2D.kt
@@ -0,0 +1,60 @@
+package io.canopy.engine.core.nodes
+
+import com.badlogic.gdx.math.Vector2
+import ktx.math.plus
+import ktx.math.times
+
+/**
+ * Base 2D Node
+ */
+abstract class Node2D> protected constructor(name: String, block: N.() -> Unit = {}) :
+ Node(name, block) {
+
+ /* ============================================================
+ * Global transform helpers
+ * ============================================================ */
+
+ /** Position in world space (local position + parent global position). */
+ val globalPosition: Vector2
+ get() {
+ val p = parent as? Node2D ?: return position
+ return position + p.globalPosition
+ }
+
+ /** Scale in world space (local scale + parent global scale). */
+ val globalScale: Vector2
+ get() {
+ val p = parent as? Node2D ?: return scale
+ return scale * p.globalScale
+ }
+
+ /** Rotation in world space (local rotation + parent global rotation). */
+ val globalRotation: Float
+ get() {
+ val p = parent as? Node2D ?: return rotation
+ return rotation + p.globalRotation
+ }
+
+ /* ============================================================
+ * Local transform
+ * ============================================================ */
+
+ /** Local position in 2D space. */
+ open var position: Vector2 = Vector2.Zero
+
+ /** Local scale in 2D space. */
+ open var scale: Vector2 = Vector2(1f, 1f)
+
+ /** Local rotation in radians. */
+ open var rotation: Float = 0f
+
+ /* ============================================================
+ * DSL helpers
+ * ============================================================ */
+
+ fun at(x: Float, y: Float) = apply { position.set(x, y) }
+ fun at(pos: Vector2) = apply { position.set(pos) }
+
+ fun scaled(x: Float, y: Float) = apply { this.scale.set(x, y) }
+ fun scaled(scale: Vector2) = apply { this.scale.set(scale) }
+}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/TreeSystem.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/TreeSystem.kt
similarity index 97%
rename from engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/TreeSystem.kt
rename to engine/core/src/main/kotlin/io/canopy/engine/core/nodes/TreeSystem.kt
index dd7c648..e0be038 100644
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/TreeSystem.kt
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/TreeSystem.kt
@@ -1,11 +1,11 @@
-package io.canopy.engine.core.nodes.core
+package io.canopy.engine.core.nodes
import kotlin.reflect.KClass
import io.canopy.engine.core.managers.SceneManager
import io.canopy.engine.core.managers.lazyManager
import io.canopy.engine.core.managers.manager
-import io.canopy.engine.logging.api.LogContext
-import io.canopy.engine.logging.engine.EngineLogs
+import io.canopy.engine.logging.EngineLogs
+import io.canopy.engine.logging.LogContext
/**
* System that spans the whole node tree and processes [Node]s accordingly
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/Behavior.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/Behavior.kt
deleted file mode 100644
index 5f987c9..0000000
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/Behavior.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-package io.canopy.engine.core.nodes.core
-
-// ===============================
-// NODE BEHAVIOR BASE
-// ===============================
-
-/**
- * Represents a custom behavior attached to a [Node].
- *
- * Behaviors allow modular logic to run on nodes without subclassing the node itself.
- *
- * See more [here](https://github.com/canopyengine/canopy-docs/blob/main/docs/manuals/core/node-system.md).
- *
- * @param N Type of the Node this behavior is attached to.
- * @property node Optional reference to the node. Can be null if detached.
- */
-abstract class Behavior>(protected open val node: N? = null) {
- /** Secondary constructor for convenience */
- constructor() : this(null)
-
- // ===============================
- // LIFECYCLE METHODS
- // ===============================
-
- /** Called when the node enters the tree */
- open fun onEnterTree() = Unit
-
- /** Called after the node and all its children have been initialized */
- open fun onReady() = Unit
-
- /** Called when the node exits the tree */
- open fun onExitTree() = Unit
-
- // ===============================
- // UPDATES
- // ===============================
-
- /**
- * Called every frame.
- * Use for rendering or non-physics logic.
- */
- open fun onUpdate(delta: Float) = Unit
-
- /**
- * Called on each physics tick.
- * Use for deterministic physics calculations.
- */
- open fun onPhysicsUpdate(delta: Float) = Unit
-}
-
-// ===============================
-// LAMBDA BEHAVIOR HELPER
-// ===============================
-
-/**
- * Convenience helper to define behaviors via lambdas instead of subclassing [Behavior].
- *
- * Example usage:
- * ```
- * val myBehavior = behavior(
- * onEnterTree = { println("Node entered tree!") },
- * onUpdate = { delta -> println("Updating with delta $delta") }
- * )
- * ```
- *
- * @param N Node type
- * @param onEnterTree Lambda called when the node enters the tree
- * @param onReady Lambda called when the node and children are ready
- * @param onExitTree Lambda called when the node exits the tree
- * @param onUpdate Lambda called every frame
- * @param onPhysicsUpdate Lambda called on physics tick
- * @return Lambda that creates a [Behavior] instance for a node
- */
-fun > N.behavior(
- onEnterTree: N.() -> Unit = {},
- onReady: N.() -> Unit = {},
- onExitTree: N.() -> Unit = {},
- onUpdate: N.(delta: Float) -> Unit = {},
- onPhysicsUpdate: N.(delta: Float) -> Unit = {},
-) {
- behavior = createBehavior(onEnterTree, onReady, onExitTree, onUpdate, onPhysicsUpdate)()
-}
-
-/**
- * Allows you to attach a behavior
- */
-fun > N.attachBehavior(builder: (node: N) -> Behavior) {
- behavior = builder(this)
-}
-
-operator fun > N.plusAssign(builder: (node: N) -> Behavior) = attachBehavior(builder)
-
-fun > createBehavior(
- onEnterTree: N.() -> Unit = {},
- onReady: N.() -> Unit = {},
- onExitTree: N.() -> Unit = {},
- onUpdate: N.(delta: Float) -> Unit = {},
- onPhysicsUpdate: N.(delta: Float) -> Unit = {},
-) = { node: N ->
- object : Behavior(node) {
- override fun onEnterTree() {
- onEnterTree(node)
- }
-
- override fun onReady() {
- onReady(node)
- }
-
- override fun onExitTree() {
- onExitTree(node)
- }
-
- override fun onUpdate(delta: Float) {
- onUpdate(node, delta)
- }
-
- override fun onPhysicsUpdate(delta: Float) {
- onPhysicsUpdate(node, delta)
- }
- }
-}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/Node.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/Node.kt
deleted file mode 100644
index 5cb86ec..0000000
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/Node.kt
+++ /dev/null
@@ -1,436 +0,0 @@
-package io.canopy.engine.core.nodes.core
-
-import kotlin.reflect.KClass
-import com.badlogic.gdx.math.Vector2
-import io.canopy.engine.core.managers.ManagersRegistry
-import io.canopy.engine.core.managers.SceneManager
-import io.canopy.engine.core.managers.lazyManager
-import io.canopy.engine.core.managers.manager
-import io.canopy.engine.logging.api.LogContext
-import io.canopy.engine.logging.engine.EngineLogs
-import ktx.math.plus
-
-/**
- * Base node class for 2D scene graph systems.
- */
-@Suppress("UNCHECKED_CAST")
-abstract class Node> protected constructor(
- /** Node name (unique among siblings) */
- name: String,
- block: N.() -> Unit,
-) {
- private var _name = name
- var name
- get() = _name
- set(value) = rename(value)
-
- // Transform properties
-
- /** Local position in 2D space */
- open var position: Vector2 = Vector2.Zero
-
- /** Local scale in 2D space */
- open var scale: Vector2 = Vector2(1f, 1f)
-
- /** Local rotation in radians */
- open var rotation: Float = 0f
- val groups: MutableList = mutableListOf()
-
- // Use a stable engine subsystem logger so it routes to engine logs.
- private val log = EngineLogs.node
-
- /** Reference to the scene manager (set automatically on init) */
- protected val sceneManager: SceneManager by lazyManager()
-
- /** Whether this node is a prefab (not active until instantiated) */
- private var isPrefab: Boolean = false
-
- /** Behavior script instance */
- internal var behavior: Behavior? = null
-
- /** Parent node reference */
- private var _parent: Node<*>? = null
- val parent get() = _parent
-
- /** Child nodes mapped by name */
- private val _children: MutableMap> = mutableMapOf()
- val children: Map> get() = _children
-
- /**
- * Full path from scene root (or from this node if detached).
- * Format: "/Root/Player/Weapon"
- */
- private var _path: String = name
- val path: String get() = _path
-
- // ===============================
- // GLOBAL TRANSFORMS
- // ===============================
-
- val globalPosition: Vector2
- get() = position + (parent?.globalPosition ?: Vector2.Zero)
-
- val globalScale: Vector2
- get() = scale + (parent?.globalScale ?: Vector2.Zero)
-
- val globalRotation: Float
- get() = rotation + (parent?.globalRotation ?: 0f)
-
- // ===============================
- // DSL SUPPORT
- // ===============================
- companion object {
- private val currentParent = ThreadLocal.withInitial?> { null }
- }
-
- init {
- // Fail fast with a real message; do not log in a require/check lambda.
- check(ManagersRegistry.has(SceneManager::class)) {
- """
- [NODE]
- You're trying to create nodes without a Scene Manager!
- This will cause critical errors along the way!
- To fix it: Register the Scene Manager on 'ManagersRegistry'.
- """.trimIndent()
- }
-
- // Attach to current DSL parent if exists
- currentParent.get()?.addChildInternal(this)
-
- // Build subtree through DSL
- val oldParent = currentParent.get()
- currentParent.set(this)
-
- try {
- create()
- block(this as N)
- } finally {
- currentParent.set(oldParent)
-
- // Optional: only log construction at TRACE to avoid noise
- LogContext.with("nodePath" to path) {
- log.trace("event" to "node.constructed") { "Node constructed" }
- }
- }
- }
-
- // ===============================
- // CHILD MANAGEMENT
- // ===============================
- private fun addChildInternal(child: Node<*>) {
- check(child.name !in children) {
- "Child with name '${child.name}' already exists under '${this.name}'"
- }
-
- _children[child.name] = child
- child._parent = this
- child.recomputePathRecursively()
-
- LogContext.with(
- "nodePath" to this.path,
- "childPath" to child.path
- ) {
- log.debug(
- "event" to "node.add_child_internal",
- "parent" to this@Node.name,
- "child" to child.name
- ) { "Attached child" }
- }
-
- sceneManager.registerSubtree(child)
- }
-
- /** Adds a child node at runtime */
- fun addChild(child: Node<*>) {
- check(child.parent == null) { "Node '${child.name}' already has a parent!" }
-
- addChildInternal(child)
-
- if (child.isPrefab) {
- LogContext.with("nodePath" to child.path) {
- log.trace("event" to "node.add_child.prefab") { "Child is prefab; skipping lifecycle" }
- }
- return
- }
-
- LogContext.with("nodePath" to child.path) {
- log.trace("event" to "node.lifecycle.enter_tree") { "enterTree()" }
- child.nodeEnterTree()
- log.trace("event" to "node.lifecycle.ready") { "ready()" }
- child.nodeReady()
- }
- }
-
- operator fun Node<*>.unaryPlus() = addChild(this)
- operator fun plusAssign(child: Node<*>) = addChild(child)
-
- /** Removes a child node */
- fun removeChild(child: Node<*>) {
- check(child.parent == this) { "Node '${child.name}' is not a child of '$name'!" }
-
- LogContext.with(
- "nodePath" to this.path,
- "childPath" to child.path
- ) {
- log.debug("event" to "node.remove_child") { "Removing child" }
- }
-
- // Lifecycle teardown
- LogContext.with("nodePath" to child.path) {
- log.trace("event" to "node.lifecycle.exit_tree") { "exitTree()" }
- child.nodeExitTree()
- }
-
- _children.remove(child.name)
- child._parent = null
- child.recomputePathRecursively()
-
- sceneManager.unregisterSubtree(child)
-
- // Cleanup: remove grandchildren
- child.children.values.toList().forEach { child.removeChild(it) }
- }
-
- operator fun Node<*>.unaryMinus() = removeChild(this)
- operator fun minusAssign(child: Node<*>) = removeChild(child)
-
- /** Removes a child node by path */
- fun removeChild(path: String) {
- val child = getNode(path)
- removeChild(child)
- }
-
- /** Returns a child node by relative path (e.g., "parent/child") */
- @Suppress("UNCHECKED_CAST")
- fun > getNode(path: String): T {
- val parts = path.split("/")
-
- if (parts.size == 1) return this.children[parts[0]] as T
-
- var current: Node<*>? =
- when (parts.first()) {
- "$" -> sceneManager.currScene
- "", "." -> this
- else -> this
- }
-
- val searchParts =
- if (parts.first() in listOf("$", ".") || path.startsWith("/")) parts.drop(1) else parts
-
- for (part in searchParts) {
- when (part) {
- "", "." -> {}
-
- ".." -> current = current?.parent ?: throw IllegalArgumentException("No parent for path: $path")
-
- else -> {
- val child = current?.children[part]
- current = child ?: throw IllegalArgumentException(
- "No child '$part' under '${current?.name}' for path '$path'"
- )
- }
- }
- }
-
- return current as? T ?: throw IllegalArgumentException("Node at path '$path' is not of expected type")
- }
-
- inline operator fun > get(path: String): T = getNode(path)
-
- /** Marks this node as a prefab (not active until instantiated) */
- fun asPrefab(): N {
- isPrefab = true
- LogContext.with("nodePath" to path) {
- log.trace("event" to "node.prefab") { "Marked as prefab" }
- }
- return this as N
- }
-
- /** Self-remove from parent */
- fun queueFree() {
- LogContext.with("nodePath" to path) {
- log.debug("event" to "node.queue_free") { "Queue free" }
- }
- parent?.removeChild(this)
- }
-
- /** Reparent a child to another node */
- fun reparent(child: Node<*>, newParent: Node<*>) {
- LogContext.with(
- "childPath" to child.path,
- "fromParent" to this.path,
- "toParent" to newParent.path
- ) {
- log.info("event" to "node.reparent") { "Reparenting child" }
- }
- removeChild(child)
- newParent.addChild(child)
- }
-
- fun hasChildType(type: KClass>) = children.values.any { it::class == type }
-
- // ===============================
- // GROUP MANAGEMENT
- // ===============================
-
- fun inGroup(group: String) = group in groups
-
- fun addGroup(group: String) = groups.add(group).also {
- LogContext.with("nodePath" to path, "group" to group) {
- log.trace("event" to "node.group.add") { "Add group" }
- }
- sceneManager.addToGroup(group, this)
- }
-
- fun removeGroup(group: String) = groups.remove(group).also {
- LogContext.with("nodePath" to path, "group" to group) {
- log.trace("event" to "node.group.remove") { "Remove group" }
- }
- sceneManager.removeFromGroup(group, this)
- }
-
- // ===============================
- // SCENE TREE BUILDING
- // ===============================
-
- fun buildTree() {
- LogContext.with("nodePath" to path) {
- log.debug("event" to "node.build_tree") { "Building tree" }
- }
- nodeEnterTree()
- nodeReady()
- }
-
- // ===============================
- // LIFECYCLE METHODS
- // ===============================
-
- /**
- * Override this function if you want a ``pre-defined`` configuration of the custom node
- *
- * > Example: Custom internal structure, behavior, etc...
- */
- open fun create() {}
-
- open fun nodeReady() {
- LogContext.with("nodePath" to path) {
- log.trace("event" to "node.ready") { "nodeReady()" }
- }
- children.values.forEach { it.nodeReady() }
- behavior?.let { runBehavior("ready") { it.onReady() } }
- }
-
- open fun nodeEnterTree() {
- LogContext.with("nodePath" to path) {
- log.trace("event" to "node.enter_tree") { "nodeEnterTree()" }
- }
- groups.forEach { sceneManager.addToGroup(it, this) }
- behavior?.let { runBehavior("enter_tree") { it.onEnterTree() } }
- children.values.forEach { it.nodeEnterTree() }
- }
-
- open fun nodeExitTree() {
- LogContext.with("nodePath" to path) {
- log.trace("event" to "node.exit_tree") { "nodeExitTree()" }
- }
- children.values.forEach { it.nodeExitTree() }
- behavior?.let { runBehavior("exit_tree") { it.onExitTree() } }
- }
-
- // ===============================
- // UPDATES
- // ===============================
-
- open fun nodeUpdate(delta: Float) {
- LogContext.with("nodePath" to path, "delta" to delta) {
- log.trace("event" to "node.update") { "nodeUpdate()" }
- }
- children.values.forEach { it.nodeUpdate(delta) }
- behavior?.let { runBehavior("update") { it.onUpdate(delta) } }
- }
-
- open fun nodePhysicsUpdate(delta: Float) {
- LogContext.with("nodePath" to path, "delta" to delta) {
- log.trace("event" to "node.physics_update") { "nodePhysicsUpdate()" }
- }
- children.values.forEach { it.nodePhysicsUpdate(delta) }
- behavior?.let { runBehavior("physics_update") { it.onPhysicsUpdate(delta) } }
- }
-
- // Helpers
- private inline fun runBehavior(phase: String, delta: Float? = null, block: () -> Unit) {
- try {
- block()
- } catch (t: Throwable) {
- val fields = buildMap {
- put("event", "behavior.error")
- put("phase", phase)
- put("nodePath", path)
- put("behavior", behavior?.javaClass?.name)
- if (delta != null) put("delta", delta)
- }.map { Pair(it.key, it.value) }
-
- EngineLogs.node.error(t = t, *fields.toTypedArray()) { "Behavior threw during $phase" }
- throw t // rethrow so you fail fast, unless you want to swallow
- }
- }
-
- private fun recomputePathRecursively() {
- _path = parent?.let { "${it.path}/$name" } ?: "/$name"
- _children.values.forEach { it.recomputePathRecursively() }
- }
-
- private fun rename(newName: String) {
- if (newName == name) return
-
- val p = parent
- if (p != null) {
- // collision check
- require(!p._children.containsKey(newName)) {
- "Sibling with name '$newName' already exists under parent '${p.path}'."
- }
-
- // update parent's index
- p._children.remove(name)
- p._children[newName] = this
- }
-
- _name = newName
- recomputePathRecursively()
- }
-
- infix fun child(node: Node<*>) = addChild(node)
-
- // Builder DSL
-
- fun at(x: Float, y: Float) = apply { position.set(x, y) }
- fun at(pos: Vector2) = apply { position.set(pos) }
-
- fun scaled(x: Float, y: Float) = apply { this.scale.set(x, y) }
- fun scaled(scale: Vector2) = apply { this.scale.set(scale) }
-
- fun groups(vararg groups: String) = apply { groups.forEach { addGroup(it) } }
-
- fun > patch(path: String, handler: T.() -> Unit) = getNode(path).apply(handler)
-}
-
-operator fun Node<*>.plus(node: Node<*>): Node<*> {
- addChild(node)
- return this
-}
-
-operator fun Node<*>.unaryPlus(): Node<*> {
- parent?.addChild(this)
- return this
-}
-
-fun Node<*>.asSceneRoot(): Node<*> {
- val sceneManager = manager()
- sceneManager.currScene = this
-
- LogContext.with("nodePath" to this.path) {
- EngineLogs.subsystem("scene").info("event" to "scene.set_root") { "Set as scene root" }
- }
-
- return this
-}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/NodeRef.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/NodeRef.kt
deleted file mode 100644
index 81ee233..0000000
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/core/NodeRef.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package io.canopy.engine.core.nodes.core
-
-import java.lang.ref.WeakReference
-
-/**
- * Represents a reference to a node.
- * This allows for lazily reference a node which may not exist, or by its path.
- * Similar to "$" in Godot
- */
-sealed class NodeRef> {
- abstract fun get(owner: Node<*>): T
-
- class DirectRef>(node: T) : NodeRef() {
- private val reference = WeakReference(node)
- override fun get(owner: Node<*>): T = reference.get()
- ?: throw IllegalStateException("Your node has no reference")
- }
-
- class PathRef>(private val path: String) : NodeRef() {
- override fun get(owner: Node<*>): T = owner.getNode(path)
- }
-}
-
-fun > nodeRef(node: T): NodeRef = NodeRef.DirectRef(node) as NodeRef
-
-fun > nodeRef(path: String): NodeRef = NodeRef.PathRef(path)
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode.kt
index 835b075..6c81bc8 100644
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode.kt
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode.kt
@@ -1,6 +1,6 @@
package io.canopy.engine.core.nodes.types.empty
-import io.canopy.engine.core.nodes.core.Node
+import io.canopy.engine.core.nodes.Node
/** Empty Node with no Behavior **/
class EmptyNode(name: String, block: EmptyNode.() -> Unit = {}) : Node(name, block)
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode2D.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode2D.kt
new file mode 100644
index 0000000..149e50d
--- /dev/null
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/nodes/types/empty/EmptyNode2D.kt
@@ -0,0 +1,5 @@
+package io.canopy.engine.core.nodes.types.empty
+
+import io.canopy.engine.core.nodes.Node2D
+
+class EmptyNode2D(name: String, block: EmptyNode2D.() -> Unit = {}) : Node2D(name, block)
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/Context.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/Context.kt
new file mode 100644
index 0000000..9bd0f7e
--- /dev/null
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/Context.kt
@@ -0,0 +1,69 @@
+package io.canopy.engine.core.reactive
+
+import io.canopy.engine.core.nodes.Node
+
+/**
+ * A transparent "scope" node used to attach contextual values to a subtree.
+ *
+ * Context scopes are implementation details of the DSL and are typically treated as invisible:
+ * - [Node.getNode] path resolution intentionally skips these nodes so you don't have to include
+ * "__context__" segments in paths.
+ *
+ * Use cases:
+ * - Provide shared configuration to a subtree (theme, tags, services)
+ * - Avoid threading values through constructors
+ *
+ * Example:
+ * ```
+ * root.context {
+ * provide("theme" to "dark", "difficulty" to 3)
+ *
+ * +PlayerNode { ... }
+ * }
+ *
+ * val theme: String = player.context("theme")
+ * ```
+ */
+class Context(
+ name: String = "__context__",
+ internal val provided: MutableMap Any?> = linkedMapOf(),
+ block: Context.() -> Unit = {},
+) : Node(name, block) {
+
+ fun provide(key: String, value: () -> T?) {
+ provided[key] = value
+ }
+}
+
+/**
+ * Resolves a context value by walking up the parent chain and searching through any
+ * [Context] encountered.
+ *
+ * Lookup rules:
+ * - Starts at `this` node and climbs to the root.
+ * - For each ancestor that is a [Context], checks if it provides [key].
+ * - The closest scope wins (nearest ancestor).
+ *
+ * @throws IllegalStateException if the key does not exist in any scope.
+ */
+@Suppress("UNCHECKED_CAST")
+fun Node<*>.resolve(key: String): T {
+ var cur: Node<*>? = this
+ while (cur != null) {
+ if (cur is Context && cur.provided.containsKey(key)) {
+ val callback = cur.provided[key] ?: error("$key not found in context")
+ return callback() as T // may be null -> will throw if T is non-null; thatβs fine
+ }
+ cur = cur.parent
+ }
+ error("Missing context key '$key' from node $path")
+}
+
+fun Node<*>.lazyResolve(key: String) = lazy { resolve(key) }
+
+/**
+ * Fetches value from [Context]s, or null if no value is found
+ */
+fun Node<*>.resolveOrNull(key: String): T? = runCatching { resolve(key) }.getOrNull()
+
+fun Node<*>.lazyResolveOrNull(key: String) = lazy { resolveOrNull(key) }
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/Event.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/Event.kt
new file mode 100644
index 0000000..fa3e736
--- /dev/null
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/Event.kt
@@ -0,0 +1,209 @@
+package io.canopy.engine.core.reactive
+
+import java.lang.ref.WeakReference
+import java.util.concurrent.CopyOnWriteArrayList
+import io.canopy.engine.logging.EngineLogs
+
+/**
+ * Simple event abstraction with weakly referenced listeners.
+ *
+ * Why weak references?
+ * - Event subscriptions are easy to forget to unsubscribe.
+ * - Weak listeners allow subscribers to be garbage-collected without leaks.
+ *
+ * Trade-offs:
+ * - A listener can disappear if nothing else strongly references it.
+ * (This is desirable for many UI/game objects, but surprising if you're not expecting it.)
+ *
+ * Threading:
+ * - Backed by [CopyOnWriteArrayList], which is safe to iterate while mutating.
+ * - This favors read-heavy patterns (many emits, few connects/disconnects).
+ *
+ * This file provides 0..2 argument event types:
+ * - [NoArgEvent]
+ * - [OneArgEvent]
+ * - [TwoArgsEvent]
+ *
+ * (Easy to extend if you need more arities.)
+ */
+sealed interface Event {
+ /** Removes all listeners. */
+ fun clear()
+
+ /** Number of currently tracked listeners (dead weak refs are cleaned up opportunistically). */
+ fun size(): Int
+
+ fun isEmpty(): Boolean = size() == 0
+
+ /** Adds a listener. */
+ infix fun connect(listener: T)
+
+ /** Removes a listener. */
+ infix fun disconnect(listener: T)
+}
+
+/**
+ * Centralizes logging for event operations.
+ * Kept separate so the event implementation stays lightweight.
+ */
+private object EventLogs {
+ // You can also expose this as EngineLogs.events if you want a dedicated subsystem.
+ val log = EngineLogs.subsystem("events")
+}
+
+/**
+ * Internal storage for weak listeners.
+ *
+ * Uses [CopyOnWriteArrayList] so that [forEach] can iterate safely without locks even while
+ * connects/disconnects happen concurrently.
+ */
+private class WeakListeners(private val kind: String) {
+
+ private val listeners = CopyOnWriteArrayList>()
+
+ fun add(listener: T) {
+ cleanupDead()
+ listeners += WeakReference(listener)
+
+ if (EventLogs.logEnabledTrace()) {
+ EventLogs.logTrace(
+ event = "event.connect",
+ fields = mapOf("kind" to kind, "listeners" to listeners.size)
+ )
+ }
+ }
+
+ fun remove(listener: T) {
+ // Remove matching listener and any dead references.
+ listeners.removeIf { it.get() == null || it.get() === listener }
+
+ if (EventLogs.logEnabledTrace()) {
+ EventLogs.logTrace(
+ event = "event.disconnect",
+ fields = mapOf("kind" to kind, "listeners" to listeners.size)
+ )
+ }
+ }
+
+ /**
+ * Iterates listeners and invokes [action] for each live listener.
+ *
+ * Dead listeners are cleaned up after iteration to keep the loop fast.
+ */
+ fun forEach(action: (T) -> Unit) {
+ var removedDead = 0
+
+ for (ref in listeners) {
+ val l = ref.get()
+ if (l == null) removedDead++ else action(l)
+ }
+
+ if (removedDead > 0) {
+ listeners.removeIf { it.get() == null }
+ }
+ }
+
+ fun clear() {
+ listeners.clear()
+
+ if (EventLogs.logEnabledTrace()) {
+ EventLogs.logTrace(event = "event.clear", fields = mapOf("kind" to kind))
+ }
+ }
+
+ fun size(): Int {
+ cleanupDead()
+ return listeners.size
+ }
+
+ private fun cleanupDead() {
+ listeners.removeIf { it.get() == null }
+ }
+}
+
+/* ============================================================
+ * Event arities
+ * ============================================================ */
+
+/** 0-argument event. */
+class NoArgEvent : Event<() -> Unit> {
+ private val callbacks = WeakListeners<() -> Unit>(kind = "0-arg")
+
+ override infix fun connect(listener: () -> Unit) = callbacks.add(listener)
+ override infix fun disconnect(listener: () -> Unit) = callbacks.remove(listener)
+
+ fun emit() {
+ if (EventLogs.logEnabledTrace()) {
+ EventLogs.logTrace(
+ event = "event.emit",
+ fields = mapOf("kind" to "0-arg", "listeners" to callbacks.size())
+ )
+ }
+ callbacks.forEach { it() }
+ }
+
+ override fun clear() = callbacks.clear()
+ override fun size(): Int = callbacks.size()
+}
+
+/** 1-argument event. */
+class OneArgEvent : Event<(A) -> Unit> {
+ private val callbacks = WeakListeners<(A) -> Unit>(kind = "1-arg")
+
+ override infix fun connect(listener: (A) -> Unit) = callbacks.add(listener)
+ override infix fun disconnect(listener: (A) -> Unit) = callbacks.remove(listener)
+
+ fun emit(a: A) {
+ if (EventLogs.logEnabledTrace()) {
+ EventLogs.logTrace(
+ event = "event.emit",
+ fields = mapOf("kind" to "1-arg", "listeners" to callbacks.size())
+ )
+ }
+ callbacks.forEach { it(a) }
+ }
+
+ override fun clear() = callbacks.clear()
+ override fun size(): Int = callbacks.size()
+}
+
+/** 2-argument event. */
+class TwoArgsEvent : Event<(A, B) -> Unit> {
+ private val callbacks = WeakListeners<(A, B) -> Unit>(kind = "2-arg")
+
+ override infix fun connect(listener: (A, B) -> Unit) = callbacks.add(listener)
+ override infix fun disconnect(listener: (A, B) -> Unit) = callbacks.remove(listener)
+
+ fun emit(a: A, b: B) {
+ if (EventLogs.logEnabledTrace()) {
+ EventLogs.logTrace(
+ event = "event.emit",
+ fields = mapOf("kind" to "2-arg", "listeners" to callbacks.size())
+ )
+ }
+ callbacks.forEach { it(a, b) }
+ }
+
+ override fun clear() = callbacks.clear()
+ override fun size(): Int = callbacks.size()
+}
+
+/* ============================================================
+ * Factory functions
+ * ============================================================ */
+
+fun event() = NoArgEvent()
+fun event() = OneArgEvent()
+fun event() = TwoArgsEvent()
+
+/* ============================================================
+ * Internal log helpers
+ * ============================================================ */
+
+private fun EventLogs.logEnabledTrace() = log.isTraceEnabled()
+
+private fun EventLogs.logTrace(event: String, fields: Map) {
+ // Note: right now we only emit the "event" field.
+ // If you want the additional fields to appear in structured logs, pass them into the logger call.
+ log.trace("event" to event) { event }
+}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/NodeRef.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/NodeRef.kt
new file mode 100644
index 0000000..58f0a40
--- /dev/null
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/NodeRef.kt
@@ -0,0 +1,73 @@
+package io.canopy.engine.core.reactive
+
+import java.lang.ref.WeakReference
+import io.canopy.engine.core.nodes.Node
+
+/**
+ * A reference to a [Node] that can be resolved later.
+ *
+ * This is useful when:
+ * - you want to point to a node that might not exist yet (path-based lookup)
+ * - you want to avoid retaining a node strongly (weak reference)
+ *
+ * Inspired by Godot's `$"path/to/node"` style lookups.
+ *
+ * Resolution:
+ * - [DirectRef] resolves to a specific node instance (stored as a [WeakReference])
+ * - [PathRef] resolves by calling [Node.getNode] on the provided owner
+ *
+ * Note:
+ * - [DirectRef] may become null if the node is garbage-collected.
+ * - [PathRef] depends on the node tree structure and may fail if nodes are renamed/moved.
+ */
+sealed class NodeRef> {
+
+ /**
+ * Resolves the referenced node relative to [owner].
+ *
+ * @throws IllegalStateException / IllegalArgumentException if resolution fails
+ */
+ abstract fun get(owner: Node<*>): T
+
+ /**
+ * Direct reference to a node instance using a [WeakReference].
+ *
+ * This avoids keeping the node alive unintentionally, but resolution may fail
+ * if the node has been garbage-collected.
+ */
+ class DirectRef>(node: T) : NodeRef() {
+ private val reference = WeakReference(node)
+
+ override fun get(owner: Node<*>): T = reference.get()
+ ?: throw IllegalStateException(
+ "Direct node reference is no longer valid (node was garbage-collected)."
+ )
+ }
+
+ /**
+ * Path-based reference resolved from an [owner] node.
+ *
+ * This is flexible (works even if the target node is created later),
+ * but it depends on the stability of node paths (names/structure).
+ */
+ class PathRef>(private val path: String) : NodeRef() {
+ override fun get(owner: Node<*>): T = owner.getNode(path)
+ }
+
+ operator fun > NodeRef.invoke(owner: Node<*>): T = get(owner)
+}
+
+/**
+ * Creates a direct (weak) reference to an existing node instance.
+ */
+fun > nodeRef(node: T): NodeRef = NodeRef.DirectRef(node)
+
+/**
+ * Creates a path reference (resolved relative to the owner passed to [NodeRef.get]).
+ *
+ * Example:
+ * ```
+ * val weaponRef = nodeRef>("$/Player/Weapon")
+ * ```
+ */
+fun > nodeRef(path: String): NodeRef = NodeRef.PathRef(path)
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/Signal.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/Signal.kt
new file mode 100644
index 0000000..84502b7
--- /dev/null
+++ b/engine/core/src/main/kotlin/io/canopy/engine/core/reactive/Signal.kt
@@ -0,0 +1,89 @@
+package io.canopy.engine.core.reactive
+
+import kotlin.properties.Delegates
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.runBlocking
+
+/**
+ * A mutable value that notifies observers when it changes.
+ *
+ * This class exposes two observation APIs:
+ *
+ * 1) Callback/event style via [connect]:
+ * - Lightweight, synchronous notification.
+ * - Listeners are stored as weak references (see [event]).
+ *
+ * 2) Flow style via [flow]:
+ * - Kotlin Flow stream with replay = 1 (new collectors receive the latest value).
+ * - [distinctUntilChanged] avoids emitting duplicates.
+ *
+ * Emission semantics:
+ * - Updates only emit when `old != new`.
+ * - The event listeners are notified immediately.
+ * - The flow emission uses `runBlocking { emit(...) }` which means setting [value]
+ * may block the calling thread if collectors are slow or the flow suspends.
+ *
+ * (This is acceptable in some engine contexts, but contributors should be aware.)
+ *
+ * @param initial Initial value of the signal (also emitted immediately to [flow]).
+ */
+class Signal(initial: T) {
+
+ /** Weak-listener event fired when [value] changes. */
+ private val valueChanged = event()
+
+ /**
+ * SharedFlow that replays the latest value to new subscribers.
+ *
+ * replay = 1 means collectors always start with the most recent value.
+ */
+ private val _flow = MutableSharedFlow(replay = 1)
+
+ /**
+ * Public flow view, filtered so it only emits when the value actually changes.
+ */
+ val flow = _flow.asSharedFlow().distinctUntilChanged()
+
+ /**
+ * Current signal value.
+ *
+ * Assigning a different value triggers:
+ * - [valueChanged.emit]
+ * - flow emission (blocking via runBlocking)
+ */
+ var value: T by Delegates.observable(initial) { _, old, new ->
+ if (old != new) {
+ valueChanged.emit(new)
+ runBlocking { _flow.emit(new) }
+ }
+ }
+
+ init {
+ // Make sure the initial value is available to flow collectors immediately.
+ _flow.tryEmit(initial)
+ }
+
+ /** Subscribes a listener to value changes (weak reference). */
+ infix fun connect(listener: (T) -> Unit) = valueChanged connect listener
+
+ /** Unsubscribes a previously registered listener. */
+ infix fun disconnect(listener: (T) -> Unit) = valueChanged disconnect listener
+
+ /** Removes all listeners registered via [connect]. */
+ fun clear() = valueChanged.clear()
+}
+
+/* ------------------------------------------------------------------
+ * Convenience factory helpers
+ * ------------------------------------------------------------------ */
+
+/** Wraps any value into a [Signal]. */
+fun T.asSignal() = signal(this)
+
+/** Creates a new [Signal] from [value]. */
+fun signal(value: T) = Signal(value)
+
+/** Convenience for nullable signals starting as null. */
+fun Nothing?.asSignal() = signal(null)
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/signals/Signal.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/signals/Signal.kt
deleted file mode 100644
index 1a3929a..0000000
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/signals/Signal.kt
+++ /dev/null
@@ -1,178 +0,0 @@
-package io.canopy.engine.core.signals
-
-import java.lang.ref.WeakReference
-import java.util.concurrent.CopyOnWriteArrayList
-import io.canopy.engine.logging.engine.EngineLogs
-
-/**
- * Generic weak-reference signal-slot implementation.
- * Supports 0..2 arguments (easy to extend).
- */
-sealed interface Signal {
- fun clear()
- fun size(): Int
- fun isEmpty(): Boolean = size() == 0
-
- infix fun connect(listener: T)
- infix fun disconnect(listener: T)
-}
-
-private object SignalLogs {
- // Put this in your EngineLogs too if you want: val signals = subsystem("signals")
- val log = EngineLogs.subsystem("signals")
-}
-
-private class WeakListeners(private val name: String? = null, private val kind: String) {
- private val listeners = CopyOnWriteArrayList>()
-
- fun add(listener: T) {
- cleanupDead()
- listeners += WeakReference(listener)
-
- if (SignalLogs.logEnabledTrace()) {
- SignalLogs.logTrace(
- event = "signal.connect",
- fields = mapOf(
- "signal" to name,
- "kind" to kind,
- "listeners" to listeners.size
- )
- )
- }
- }
-
- fun remove(listener: T) {
- // remove matching or dead
- listeners.removeIf { it.get() == null || it.get() === listener }
-
- if (SignalLogs.logEnabledTrace()) {
- SignalLogs.logTrace(
- event = "signal.disconnect",
- fields = mapOf(
- "signal" to name,
- "kind" to kind,
- "listeners" to listeners.size
- )
- )
- }
- }
-
- fun forEach(action: (T) -> Unit) {
- // Iterate snapshot; remove dead afterward
- var removedDead = 0
- for (ref in listeners) {
- val l = ref.get()
- if (l == null) {
- removedDead++
- } else {
- action(l)
- }
- }
- if (removedDead > 0) {
- listeners.removeIf { it.get() == null }
- }
- }
-
- fun clear() {
- listeners.clear()
-
- if (SignalLogs.logEnabledTrace()) {
- SignalLogs.logTrace(
- event = "signal.clear",
- fields = mapOf(
- "signal" to name,
- "kind" to kind
- )
- )
- }
- }
-
- fun size(): Int {
- cleanupDead()
- return listeners.size
- }
-
- private fun cleanupDead() {
- listeners.removeIf { it.get() == null }
- }
-}
-
-/** 0-argument signal */
-class NoArgSignal(private val name: String? = null) : Signal<() -> Unit> {
- private val callbacks = WeakListeners<() -> Unit>(name = name, kind = "0-arg")
-
- override infix fun connect(listener: () -> Unit) = callbacks.add(listener)
- override infix fun disconnect(listener: () -> Unit) = callbacks.remove(listener)
-
- fun emit() {
- if (SignalLogs.logEnabledTrace()) {
- SignalLogs.logTrace(
- event = "signal.emit",
- fields = mapOf("signal" to name, "kind" to "0-arg", "listeners" to callbacks.size())
- )
- }
- callbacks.forEach { it() }
- }
-
- override fun clear() = callbacks.clear()
- override fun size(): Int = callbacks.size()
-}
-
-/** 1-argument signal */
-class OneArgSignal(private val name: String? = null) : Signal<(A) -> Unit> {
- private val callbacks = WeakListeners<(A) -> Unit>(name = name, kind = "1-arg")
-
- override infix fun connect(listener: (A) -> Unit) = callbacks.add(listener)
- override infix fun disconnect(listener: (A) -> Unit) = callbacks.remove(listener)
-
- fun emit(a: A) {
- if (SignalLogs.logEnabledTrace()) {
- SignalLogs.logTrace(
- event = "signal.emit",
- fields = mapOf("signal" to name, "kind" to "1-arg", "listeners" to callbacks.size())
- )
- }
- callbacks.forEach { it(a) }
- }
-
- override fun clear() = callbacks.clear()
- override fun size(): Int = callbacks.size()
-}
-
-/** 2-argument signal */
-class TwoArgsSignal(private val name: String? = null) : Signal<(A, B) -> Unit> {
- private val callbacks = WeakListeners<(A, B) -> Unit>(name = name, kind = "2-arg")
-
- override infix fun connect(listener: (A, B) -> Unit) = callbacks.add(listener)
- override infix fun disconnect(listener: (A, B) -> Unit) = callbacks.remove(listener)
-
- fun emit(a: A, b: B) {
- if (SignalLogs.logEnabledTrace()) {
- SignalLogs.logTrace(
- event = "signal.emit",
- fields = mapOf("signal" to name, "kind" to "2-arg", "listeners" to callbacks.size())
- )
- }
- callbacks.forEach { it(a, b) }
- }
-
- override fun clear() = callbacks.clear()
- override fun size(): Int = callbacks.size()
-}
-
-// ---------- Factory functions ----------
-
-fun createSignal(name: String? = null) = NoArgSignal(name)
-
-fun createSignal(name: String? = null) = OneArgSignal(name)
-
-fun createSignal(name: String? = null) = TwoArgsSignal(name)
-
-// ---------- Small internal log helpers ----------
-
-private fun SignalLogs.logEnabledTrace() = log.isTraceEnabled()
-
-private fun SignalLogs.logTrace(event: String, fields: Map) {
- // Log at trace, but keep message constant; structured fields carry info.
- log.trace("event" to event) { event }
-}
diff --git a/engine/core/src/main/kotlin/io/canopy/engine/core/signals/SignalVal.kt b/engine/core/src/main/kotlin/io/canopy/engine/core/signals/SignalVal.kt
deleted file mode 100644
index 71fd731..0000000
--- a/engine/core/src/main/kotlin/io/canopy/engine/core/signals/SignalVal.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package io.canopy.engine.core.signals
-
-import kotlin.properties.Delegates
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.runBlocking
-
-/**
- * A variable that emits_ a signal when its value changes.
- * @property value The current value of the variable.
- * @property flow A StateFlow that emits the current value and updates on changes.
- * Useful for data binding and reactive programming.
- */
-class SignalVal(initial: T) {
- private val valueChanged = createSignal()
- private val _flow = MutableSharedFlow(replay = 1) // replay last value
- val flow = _flow.asSharedFlow().distinctUntilChanged() // distinct until changed
-
- var value: T by Delegates.observable(initial) { _, old, new ->
- if (old != new) {
- valueChanged.emit(new)
- runBlocking { _flow.emit(new) }
- }
- }
-
- init {
- _flow.tryEmit(initial) // emit initial value
- }
-
- infix fun connect(listener: (T) -> Unit) = valueChanged connect listener
-
- infix fun disconnect(listener: (T) -> Unit) = valueChanged disconnect listener
-
- fun clear() = valueChanged.clear()
-}
-
-// Convenience functions to create SignalVal instances
-fun T.asSignalVal() = SignalVal(this)
-
-fun Nothing?.asNullableSignalVal() = SignalVal(null)
diff --git a/engine/core/src/test/kotlin/io/canopy/engine/core/nodes/NodeRefTests.kt b/engine/core/src/test/kotlin/io/canopy/engine/core/nodes/NodeRefTests.kt
index 2dfe68c..d1730a6 100644
--- a/engine/core/src/test/kotlin/io/canopy/engine/core/nodes/NodeRefTests.kt
+++ b/engine/core/src/test/kotlin/io/canopy/engine/core/nodes/NodeRefTests.kt
@@ -2,17 +2,22 @@ package io.canopy.engine.core.nodes
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
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.core.nodes.core.Node
-import io.canopy.engine.core.nodes.core.NodeRef
-import io.canopy.engine.core.nodes.core.behavior
-import io.canopy.engine.core.nodes.core.nodeRef
import io.canopy.engine.core.nodes.types.empty.EmptyNode
+import io.canopy.engine.core.reactive.NodeRef
+import io.canopy.engine.core.reactive.nodeRef
import org.junit.jupiter.api.BeforeAll
class NodeRefTests {
+
+ /**
+ * Node that stores an external node reference.
+ *
+ * In real usage this pattern is common for "wiring" nodes together without
+ * requiring the target to be constructed first.
+ */
private class NeedsRef(name: String, val external: NodeRef<*>, block: NeedsRef.() -> Unit = {}) :
Node(name, block)
@@ -20,6 +25,7 @@ class NodeRefTests {
@BeforeAll
@JvmStatic
fun setup() {
+ // Tests run in a shared JVM; reset manager state to avoid cross-test contamination.
ManagersRegistry.withScope {
register(SceneManager())
}
@@ -27,26 +33,31 @@ class NodeRefTests {
}
@Test
- fun `should reference node`() {
- var referencedNode: String = ""
+ fun `path nodeRef should resolve from scene root`() {
+ var referencedNodeName: String? = null
+ // Build a tree where "referrer" holds a reference to "$/external".
val tree =
EmptyNode(name = "root") {
NeedsRef(
name = "referrer",
- external = nodeRef("$/external")
+ external = nodeRef("$/external") // `$` means "resolve from current scene root"
) {
behavior(
onReady = {
- referencedNode = external.get(this).name
+ // Resolve the reference relative to this node.
+ referencedNodeName = external.get(this).name
}
)
}
+
EmptyNode(name = "external")
- }
+ }.asSceneRoot()
- manager().currScene = tree
+ // Triggers enterTree + ready; behaviors run in ready.
+ tree.buildTree()
- assertEquals("external", referencedNode)
+ assertNotNull(referencedNodeName, "Expected reference to be resolved during onReady()")
+ assertEquals("external", referencedNodeName)
}
}
diff --git a/engine/core/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt b/engine/core/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt
index 6ff68ef..3df718b 100644
--- a/engine/core/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt
+++ b/engine/core/src/test/kotlin/io/canopy/engine/core/nodes/NodeTests.kt
@@ -6,22 +6,20 @@ import kotlin.time.toDuration
import com.badlogic.gdx.math.Vector2
import io.canopy.engine.core.managers.ManagersRegistry
import io.canopy.engine.core.managers.SceneManager
-import io.canopy.engine.core.nodes.core.Node
-import io.canopy.engine.core.nodes.core.asSceneRoot
-import io.canopy.engine.core.nodes.core.attachBehavior
-import io.canopy.engine.core.nodes.core.behavior
-import io.canopy.engine.core.nodes.core.createBehavior
import io.canopy.engine.core.nodes.types.empty.EmptyNode
+import io.canopy.engine.core.nodes.types.empty.EmptyNode2D
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.BeforeAll
class NodeTests {
+
companion object {
@BeforeAll
@JvmStatic
fun setup() {
+ // Tests share the same JVM; ensure a clean manager baseline.
ManagersRegistry.withScope {
register(SceneManager())
}
@@ -30,8 +28,7 @@ class NodeTests {
@Test
fun `structure should pass`() {
- val root = EmptyNode("test-node")
-
+ // Verifies DSL-built hierarchy and parent pointers.
val scene = EmptyNode("root") {
EmptyNode("child-a")
@@ -42,42 +39,39 @@ class NodeTests {
scene.buildTree()
- assertSame(2, scene.children.size)
- assertSame(scene, scene.getNode("child-b").parent)
+ assertEquals(2, scene.children.size)
+ assertSame(scene, scene.getNode("child-b").parent)
assertSame(
- scene.getNode("child-b"),
- scene.getNode("child-b/child-c").parent
+ scene.getNode("child-b"),
+ scene.getNode("child-b/child-c").parent
)
}
@Test
fun `behavior should work`() {
+ // Verifies behavior factory attachment and that behavior can access parent/name.
val childCount: MutableMap = mutableMapOf()
- // Behavior factory lambda
val lambdaBehavior =
createBehavior(
onReady = {
val parent = parent ?: return@createBehavior
childCount.merge(parent.name, 1) { old, new -> old + new }
- if (name !in childCount) {
- childCount[name] = 0
- }
+ if (name !in childCount) childCount[name] = 0
}
)
- // Build scene
EmptyNode("Test 2") {
EmptyNode("child-a") {
attachBehavior(lambdaBehavior)
- } // pass node
+ }
EmptyNode("child-b") {
attachBehavior(lambdaBehavior)
EmptyNode("child-c") {
attachBehavior(lambdaBehavior)
- } // pass node
+ }
}
}.buildTree()
@@ -94,27 +88,28 @@ class NodeTests {
@Test
fun `ready should execute on correct order`() {
+ // Verifies ready order for the current lifecycle implementation:
+ // children first, then parent, with depth-first traversal.
val callOrder = mutableListOf()
+
val behaviour =
createBehavior(
- onReady = {
- callOrder += name
- }
+ onReady = { callOrder += name }
)
- // Build scene
EmptyNode("Test 2") {
attachBehavior(behaviour)
EmptyNode("child-a") {
- attachBehavior(behaviour) // pass node
+ attachBehavior(behaviour)
}
EmptyNode("child-b") {
attachBehavior(behaviour)
+
EmptyNode("child-c") {
attachBehavior(behaviour)
- } // pass node
+ }
}
}.buildTree()
@@ -131,17 +126,14 @@ class NodeTests {
@Test
fun `ticks should update state`() = runBlocking {
+ // Verifies that nodeUpdate and nodePhysicsUpdate trigger behavior callbacks.
var nTicks = 0
var nPhysicsTicks = 0
val behavior =
createBehavior(
- onUpdate = {
- nTicks++
- },
- onPhysicsUpdate = {
- nPhysicsTicks++
- }
+ onUpdate = { nTicks++ },
+ onPhysicsUpdate = { nPhysicsTicks++ }
)
val tree = EmptyNode("root") {
@@ -151,6 +143,7 @@ class NodeTests {
launch {
repeat(2) { i ->
+ // Simulate "physics tick occasionally"
if (nTicks % (i + 1) == 0) {
tree.nodePhysicsUpdate(0f)
}
@@ -166,7 +159,9 @@ class NodeTests {
@Test
fun `adding should call ready on child node`() {
+ // Verifies runtime addChild triggers lifecycle for non-prefab children.
var wasCalled = false
+
val behavior =
createBehavior(
onReady = { wasCalled = true }
@@ -186,7 +181,9 @@ class NodeTests {
@Test
fun `removing node should call onExitTree`() {
+ // Verifies runtime removal triggers exitTree lifecycle.
var wasCalled = false
+
val behavior =
createBehavior(
onExitTree = { wasCalled = true }
@@ -197,23 +194,26 @@ class NodeTests {
EmptyNode("child") {
attachBehavior(behavior)
}
- }
+ }.asSceneRoot()
+
+ tree.buildTree()
assertFalse(wasCalled)
tree.removeChild("child")
-
assertTrue(wasCalled)
}
@Test
fun `queue free should delete node`() {
+ // Verifies queueFree removes a node from its parent.
val tree =
EmptyNode("root") {
EmptyNode("child")
}
+
tree.buildTree()
- val child = tree.getNode("child")
+ val child = tree.getNode("child")
assertNotNull(child)
child.queueFree()
@@ -223,6 +223,7 @@ class NodeTests {
@Test
fun `custom scene should work`() {
+ // Verifies create() can define internal structure.
class CustomScene(name: String = "custom", block: CustomScene.() -> Unit = {}) :
Node(name, block) {
override fun create() {
@@ -235,45 +236,48 @@ class NodeTests {
EmptyNode("child")
}
+ customScene.buildTree()
+
assertEquals(2, customScene.children.size)
}
@Test
fun `patching internal node should work`() {
+ // Verifies patch() can locate and mutate internally created nodes by path.
class CustomScene(name: String = "custom", block: CustomScene.() -> Unit = {}) :
Node(name, block) {
override fun create() {
- EmptyNode("empty")
+ EmptyNode2D("empty")
}
}
val node = CustomScene {
- patch("./empty") {
+ patch("./empty") {
name = "patched"
at(100f, 100f)
}
}
+ node.buildTree()
- val child = node.getNode("./patched")
+ val child = node.getNode("./patched")
assertEquals("patched", child.name)
assertEquals(Vector2(100f, 100f), child.position)
}
@Test
- fun `custom node class with internal script should work`() {
+ fun `custom node class with internal script should work`() {
+ // Verifies a node can attach behavior from within create().
var wasCalled = false
- class CustomScene(name: String, block: CustomScene.() -> Unit = {}) : Node(name, block = block) {
+ class CustomScene(name: String, block: CustomScene.() -> Unit = {}) :
+ Node(name, block = block) {
override fun create() {
- behavior(
- onReady = { wasCalled = true }
- )
+ behavior(onReady = { wasCalled = true })
}
}
val root = CustomScene("root").asSceneRoot()
-
root.buildTree()
assertTrue(wasCalled)
diff --git a/engine/core/src/test/kotlin/io/canopy/engine/core/reactive/ContextTests.kt b/engine/core/src/test/kotlin/io/canopy/engine/core/reactive/ContextTests.kt
new file mode 100644
index 0000000..1eb068f
--- /dev/null
+++ b/engine/core/src/test/kotlin/io/canopy/engine/core/reactive/ContextTests.kt
@@ -0,0 +1,227 @@
+package io.canopy.engine.core.reactive
+
+import io.canopy.engine.core.managers.ManagersRegistry
+import io.canopy.engine.core.managers.SceneManager
+import io.canopy.engine.core.nodes.Node
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+/**
+ * Adjust `n(name)` helper if your Node concrete class differs.
+ */
+class ContextTests {
+
+ companion object {
+
+ @JvmStatic
+ @BeforeAll
+ fun setup() {
+ ManagersRegistry.withScope {
+ +SceneManager()
+ }
+ }
+ }
+
+ // --- Helpers ------------------------------------------------------------
+
+ private class EmptyNode(name: String, block: EmptyNode.() -> Unit = {}) : Node(name, block)
+
+ private fun n(name: String, block: EmptyNode.() -> Unit = {}) = EmptyNode(name, block)
+
+ private fun Node<*>.child(name: String, block: EmptyNode.() -> Unit = {}): Node<*> {
+ val c = n(name, block)
+ addChild(c)
+ return c
+ }
+
+ private object DebugModeKey // any object key (no ::class)
+ private object SeasonKey
+
+ // --- Tests --------------------------------------------------------------
+
+ @Test
+ fun `context resolves from nearest scope`() {
+ val root = n("root") {
+ Context {
+ provide("debug") { true }
+ n("child") {
+ // child node exists under scope
+ }
+ }
+ }
+
+ root.buildTree()
+
+ // Find the "child" node. Adjust if you have find-by-path utilities.
+ val child = root.getNode("./child")
+
+ val debug: Boolean = child.resolve("debug")
+ assertTrue(debug)
+ }
+
+ @Test
+ fun `context resolves from ancestor scope when not provided locally`() {
+ val root = n("root") {
+ Context {
+ provide("debug") { true }
+ n("a") {
+ n("b") { }
+ }
+ }
+ }
+
+ root.buildTree()
+
+ val b = root.getNode("./a/b")
+
+ val debug: Boolean = b.resolve("debug")
+ assertTrue(debug)
+ }
+
+ @Test
+ fun `nearest provider wins (shadowing overrides)`() {
+ val root = n("root") {
+ Context {
+ provide("debug") { true }
+
+ n("a") { }
+
+ Context {
+ provide("debug") { false }
+ n("b") { }
+ }
+ }
+ }
+
+ root.buildTree()
+
+ val a = root.getNode("./a")
+ val b = root.getNode("./b")
+
+ val aDebug: Boolean = a.resolve("debug")
+ val bDebug: Boolean = b.resolve("debug")
+
+ assertTrue(aDebug)
+ assertFalse(bDebug)
+ }
+
+ @Test
+ fun `contextOrNull returns null when missing`() {
+ val root = n("root") { n("child") { } }
+
+ root.buildTree()
+
+ val child = root.getNode("./child")
+
+ val missing: String? = child.resolveOrNull("nope")
+ assertNull(missing)
+ }
+
+ @Test
+ fun `context throws when missing`() {
+ val root = n("root") { n("child") { } }
+
+ root.buildTree()
+
+ val child = root.getNode("./child")
+
+ val ex = assertThrows {
+ child.resolve("missing")
+ }
+
+ // Optional: if your error message includes path/name
+ assertTrue(ex.message!!.contains("missing", ignoreCase = true))
+ }
+
+ @Test
+ fun `supports non-string keys (Any keys)`() {
+ val root = n("root") {
+ Context {
+ provide("debugMode") { true }
+ provide("season") { "winter" }
+ n("child") { }
+ }
+ }
+
+ root.buildTree()
+
+ val child = root.getNode("./child")
+
+ val debug: Boolean = child.resolve("debugMode")
+ val season: String = child.resolve("season")
+
+ assertTrue(debug)
+ assertEquals("winter", season)
+ }
+
+ @Test
+ fun `multiple keys of same value type do not collide`() {
+ val root = n("root") {
+ Context {
+ provide("keyA") { 1 }
+ provide("keyB") { 2 }
+ n("child") { }
+ }
+ }
+
+ root.buildTree()
+
+ val child = root.getNode("./child")
+
+ assertEquals(1, child.resolve("keyA"))
+ assertEquals(2, child.resolve("keyB"))
+ }
+
+ @Test
+ fun `deep nesting still resolves correctly`() {
+ val root = n("root") {
+ Context {
+ provide("x") { 42 }
+
+ n("a") {
+ n("b") {
+ n("c") { }
+ }
+ }
+ }
+ }
+
+ root.buildTree()
+
+ val c = root.getNode("./a/b/c")
+ assertEquals(42, c.resolve("x"))
+ }
+
+ @Test
+ fun `nested contexts should feed into each other`() {
+ val root = n("root") {
+ Context {
+ provide("keyA") { 1 }
+
+ Context {
+ provide("keyB") { 2 }
+
+ n("a")
+ }
+ }
+ }
+ root.buildTree()
+
+ val c = root.getNode("./a")
+
+ assertEquals(1, c.resolve("keyA"))
+ }
+
+ // --- Tiny adapter -------------------------------------------------------
+ // If your Node doesn't expose children()/name, replace these calls with your real APIs.
+
+ private fun Node<*>.children(): List> = // Replace with your actual children accessor
+ // e.g. this.children.values.toList()
+ (this as DynamicChildren).childrenList()
+
+ private interface DynamicChildren {
+ fun childrenList(): List>
+ }
+}
diff --git a/engine/core/src/test/kotlin/io/canopy/engine/core/signals/SignalsTests.kt b/engine/core/src/test/kotlin/io/canopy/engine/core/reactive/EventTests.kt
similarity index 56%
rename from engine/core/src/test/kotlin/io/canopy/engine/core/signals/SignalsTests.kt
rename to engine/core/src/test/kotlin/io/canopy/engine/core/reactive/EventTests.kt
index 5f1a08c..bcd755f 100644
--- a/engine/core/src/test/kotlin/io/canopy/engine/core/signals/SignalsTests.kt
+++ b/engine/core/src/test/kotlin/io/canopy/engine/core/reactive/EventTests.kt
@@ -1,4 +1,4 @@
-package io.canopy.engine.core.signals
+package io.canopy.engine.core.reactive
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@@ -8,29 +8,48 @@ import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
-class SignalsTests {
+/**
+ * Tests for the weak-listener event system.
+ *
+ * The event system guarantees:
+ * - listeners receive emitted values
+ * - disconnecting a listener stops future notifications
+ * - disconnecting one listener does not affect others
+ * - emissions are safe under concurrent producers
+ * - clear() removes all listeners
+ */
+class EventTests {
+
@Test
fun `callback should be called on signal emission`() {
- val signal = createSignal()
+ // Create a 1-argument signal
+ val signal = event()
+ // Capture the value received by the listener
var receivedValue: Int? = null
val callback: (Int) -> Unit = { value -> receivedValue = value }
+ // Register listener
signal connect callback
- signal.emit(42)
+ // Emit a value β listener should receive it
+ signal.emit(42)
assert(receivedValue == 42) { "Listener should have received the emitted value." }
+ // Disconnect listener
signal disconnect callback
+
+ // Reset and emit again
receivedValue = null
signal.emit(100)
+ // Listener should not be triggered after disconnection
assert(receivedValue == null) { "Listener should not receive value after disconnection." }
}
@Test
fun `signal disconnection shouldn't impact other listeners`() {
- val signal = createSignal()
+ val signal = event()
var receivedByFirst: Int? = null
var receivedBySecond: Int? = null
@@ -38,24 +57,26 @@ class SignalsTests {
val firstCallback: (Int) -> Unit = { value -> receivedByFirst = value }
val secondCallback: (Int) -> Unit = { value -> receivedBySecond = value }
- // Connect both callbacks
+ // Register both listeners
signal connect firstCallback
signal connect secondCallback
- // Emit a value, both should receive it
+ // Both listeners should receive the emission
signal.emit(10)
assert(receivedByFirst == 10) { "First callback should have received 10" }
assert(receivedBySecond == 10) { "Second callback should have received 10" }
- // Disconnect the first callback
+ // Disconnect the first listener
signal disconnect firstCallback
- // Reset
+ // Reset state
receivedByFirst = null
receivedBySecond = null
- // Emit another value
+ // Emit again
signal.emit(20)
+
+ // Only the second listener should receive the value
assert(receivedByFirst == null) { "First callback should not receive value after disconnection" }
assert(receivedBySecond == 20) { "Second callback should still receive emitted value" }
}
@@ -63,15 +84,21 @@ class SignalsTests {
@OptIn(ExperimentalAtomicApi::class)
@Test
fun `test signal thread-safety`() = runBlocking {
+ /**
+ * Verifies that the event system behaves correctly under concurrent emission.
+ *
+ * Multiple threads emit values simultaneously and the listener aggregates them
+ * using an atomic counter.
+ */
+
val container = AtomicInt(0)
- val signal = createSignal()
+ val signal = event()
val callback: (Int) -> Unit = { value -> container.addAndFetch(value) }
- // Connect listener
- signal connect callback // signal.connect(callback)
+ signal connect callback
- // Launch multiple concurrent producers on different threads
+ // Launch multiple concurrent producers
val producers =
List(10) {
launch(Dispatchers.Default) {
@@ -80,25 +107,40 @@ class SignalsTests {
}
}
}
+
// Wait for all producers to finish
producers.joinAll()
- // Verify result
- assert(container.load() == 10_000) { "Container should be 10000 but was ${container.load()}" }
+
+ // Expected result: 10 threads Γ 1000 emits
+ assert(container.load() == 10_000) {
+ "Container should be 10000 but was ${container.load()}"
+ }
}
@Test
fun `test signal clear`() {
- val signal = createSignal()
+ /**
+ * Verifies that clear() removes all registered listeners.
+ */
+
+ val signal = event()
var callCount = 0
val callback: (Int) -> Unit = { _ -> callCount++ }
signal connect callback
+ // First emission should trigger listener
signal.emit(42)
+
+ // Clear all listeners
signal.clear()
+
+ // Second emission should trigger nothing
signal.emit(100)
- assert(callCount == 1) { "Listener should have been called only once before clear." }
+ assert(callCount == 1) {
+ "Listener should have been called only once before clear."
+ }
}
}
diff --git a/engine/core/src/test/kotlin/io/canopy/engine/core/reactive/SignalTests.kt b/engine/core/src/test/kotlin/io/canopy/engine/core/reactive/SignalTests.kt
new file mode 100644
index 0000000..534a703
--- /dev/null
+++ b/engine/core/src/test/kotlin/io/canopy/engine/core/reactive/SignalTests.kt
@@ -0,0 +1,132 @@
+package io.canopy.engine.core.reactive
+
+import kotlin.test.Test
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.yield
+import org.junit.jupiter.api.Assertions.assertEquals
+
+/**
+ * Tests for [Signal], a mutable value that notifies observers when it changes.
+ *
+ * Signal supports two observation mechanisms:
+ * - callback/event style via `connect` (weak listeners)
+ * - Kotlin Flow via `flow` (replay = 1, distinctUntilChanged)
+ *
+ * These tests document the expected contracts.
+ */
+class SignalTests {
+
+ @Test
+ fun `signal should notify listeners on value change`() {
+ val signal = signal(0)
+
+ var receivedValue: Int? = null
+ val callback: (Int) -> Unit = { value -> receivedValue = value }
+
+ // Register listener
+ signal connect callback
+
+ // Mutate the signal
+ signal.value = 42
+
+ // Listener should receive the new value
+ assert(receivedValue == 42) { "Listener should have received the emitted value." }
+ }
+
+ @Test
+ fun `signal should not notify listeners when setting same value`() {
+ val signal = signal(0)
+
+ var callCount = 0
+ val callback: (Int) -> Unit = { _ -> callCount++ }
+
+ signal connect callback
+
+ // First change should notify
+ signal.value = 42
+
+ // Reassigning the same value should NOT notify
+ signal.value = 42
+ signal.value = 42
+
+ assert(callCount == 1) { "Listener should have been called only once." }
+ }
+
+ @Test
+ fun `signal disconnect should stop notifications`() {
+ val signal = signal(0)
+
+ var callCount = 0
+ val callback: (Int) -> Unit = { _ -> callCount++ }
+
+ signal connect callback
+
+ // Listener should be called once
+ signal.value = 42
+
+ // After disconnect, listener should no longer be invoked
+ signal disconnect callback
+ signal.value = 100
+
+ assert(callCount == 1) { "Listener should have been called only once before disconnection." }
+ }
+
+ @Test
+ fun `signal clear should remove all listeners`() {
+ val signal = signal(0)
+
+ var callCount = 0
+ val callback: (Int) -> Unit = { _ -> callCount++ }
+
+ signal connect callback
+
+ // First update triggers listener
+ signal.value = 42
+
+ // Clear removes all listeners
+ signal.clear()
+
+ // Further updates should not trigger the callback
+ signal.value = 100
+
+ assert(callCount == 1) { "Listener should have been called only once before clear." }
+ }
+
+ @Test
+ fun `signal flow should replay initial and emit distinct values`() = runBlocking {
+ val signal = signal(0)
+
+ val collectedValues = mutableListOf()
+
+ // Collect from the flow:
+ // - replay = 1 means we should immediately receive the current value (0)
+ // - distinctUntilChanged means duplicates should not be emitted
+ val job = launch {
+ signal.flow.collect { collectedValues.add(it) }
+ }
+
+ // Update values
+ signal.value = 42
+ signal.value = 100
+ signal.value = 100 // duplicate -> should not be collected (distinctUntilChanged)
+
+ // Give collector a chance to run
+ yield()
+ job.cancel()
+
+ assertEquals(listOf(0, 42, 100), collectedValues)
+ }
+
+ @Test
+ fun `asSignal should wrap a value and allow updates`() {
+ val signalVal = 10.asSignal()
+
+ // Initial value should be preserved
+ assertEquals(10, signalVal.value) { "Wrapped value should match the initial value." }
+
+ // Updating the signal should update its stored value
+ signalVal.value = 20
+ assertEquals(20, signalVal.value) { "Wrapped value should update when assigned." }
+ }
+}
diff --git a/engine/core/src/test/kotlin/io/canopy/engine/core/signals/SignalValTests.kt b/engine/core/src/test/kotlin/io/canopy/engine/core/signals/SignalValTests.kt
deleted file mode 100644
index 6a92a27..0000000
--- a/engine/core/src/test/kotlin/io/canopy/engine/core/signals/SignalValTests.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-package io.canopy.engine.core.signals
-
-import kotlin.test.Test
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.yield
-import org.junit.jupiter.api.Assertions.assertEquals
-
-class SignalValTests {
- @Test
- fun `test signal val`() {
- val signalVal = SignalVal(0) // or 0.asSignalVal()
-
- var receivedValue: Int? = null
- val callback: (Int) -> Unit = { value -> receivedValue = value }
-
- signalVal connect callback
-
- signalVal.value = 42
-
- assert(receivedValue == 42) { "Listener should have received the emitted value." }
- }
-
- @Test
- fun `test signal val no callback on same value`() {
- val signalVal = SignalVal(0) // or 0.asSignalVal()
-
- var callCount = 0
- val callback: (Int) -> Unit = { _ -> callCount++ }
-
- signalVal connect callback
-
- signalVal.value = 42
- signalVal.value = 42
- signalVal.value = 42
-
- assert(callCount == 1) { "Listener should have been called only once." }
- }
-
- @Test
- fun `test signal val disconnect`() {
- val signalVal = SignalVal(0) // or 0.asSignalVal()
-
- var callCount = 0
- val callback: (Int) -> Unit = { _ -> callCount++ }
-
- signalVal connect callback
-
- signalVal.value = 42
- signalVal disconnect callback
- signalVal.value = 100
-
- assert(callCount == 1) { "Listener should have been called only once before disconnection." }
- }
-
- @Test
- fun `test signal val clear`() {
- val signalVal = SignalVal(0) // or 0.asSignalVal()
-
- var callCount = 0
- val callback: (Int) -> Unit = { _ -> callCount++ }
-
- signalVal connect callback
-
- signalVal.value = 42
- signalVal.clear()
- signalVal.value = 100
-
- assert(callCount == 1) { "Listener should have been called only once before clear." }
- }
-
- @Test
- fun `test signal val flow`() = runBlocking {
- val signalVal = SignalVal(0)
-
- val collectedValues = mutableListOf()
-
- // Start collecting immediately
- val job =
- launch {
- signalVal.flow.collect {
- collectedValues.add(it)
- }
- }
-
- // Update values
- signalVal.value = 42
- signalVal.value = 100
- signalVal.value = 100 // duplicate, should not emit (SharedFlow allows it, duplicates may remain)
-
- // Give coroutine a chance to collect all
- yield()
- job.cancel() // Stop collecting
-
- assertEquals(listOf(0, 42, 100), collectedValues)
- }
-
- @Test
- fun `test signal val unwrapped`() {
- val signalVal = 10.asSignalVal()
- assertEquals(10, signalVal.value) { "Unwrapped value should match the current value." }
-
- signalVal.value = 20
- assertEquals(20, signalVal.value) { "Unwrapped value should update with the current value." }
- }
-}
diff --git a/engine/data/data-core/build.gradle.kts b/engine/data/data-core/build.gradle.kts
index 1caeb5f..617e185 100644
--- a/engine/data/data-core/build.gradle.kts
+++ b/engine/data/data-core/build.gradle.kts
@@ -12,7 +12,8 @@ dependencies {
// Ktx
api(libs.ktx.assets)
- // JSON
+ // Serialization
+ api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
// TOML
diff --git a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/assets/AssetsManager.kt b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/assets/AssetsManager.kt
index 0d9d885..974bdc8 100644
--- a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/assets/AssetsManager.kt
+++ b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/assets/AssetsManager.kt
@@ -6,25 +6,70 @@ import io.canopy.engine.core.managers.Manager
import ktx.assets.*
/**
- * Manages asset loading
+ * Manages direct asset loading for the engine.
+ *
+ * This manager provides simple helpers for loading files and textures
+ * from different libGDX file systems.
+ *
+ * Unlike libGDX's `AssetManager`, this class performs **immediate loading**
+ * and does not manage asset lifetimes, caching, or async loading.
+ *
+ * Typical usage:
+ *
+ * ```kotlin
+ * val texture = manager().loadTexture("player.png", FileSource.Internal)
+ * ```
*/
class AssetsManager : Manager {
+
+ /**
+ * Loads a [Texture] from the given path and file source.
+ *
+ * @param path Path to the asset.
+ * @param source File system to load the asset from.
+ * @param customOptions Optional configuration applied to the texture after creation.
+ */
fun loadTexture(path: String, source: FileSource, customOptions: Texture.() -> Unit = {}): Texture =
Texture(loadFile(path, source)).apply { customOptions() }
- fun loadFile(path: String, source: FileSource, customOptions: FileHandle.() -> Unit = {}) = when (source) {
- FileSource.Internal -> path.toInternalFile()
- FileSource.External -> path.toExternalFile()
- FileSource.Classpath -> path.toClasspathFile()
- FileSource.Local -> path.toLocalFile()
- FileSource.Absolute -> path.toAbsoluteFile()
- }.apply { customOptions() }
+ /**
+ * Loads a [FileHandle] from the specified file source.
+ *
+ * This is a thin wrapper over KTX's file helpers.
+ *
+ * @param path Path to the file.
+ * @param source File system to load from.
+ * @param customOptions Optional configuration applied to the resulting file handle.
+ */
+ fun loadFile(path: String, source: FileSource, customOptions: FileHandle.() -> Unit = {}): FileHandle =
+ when (source) {
+ FileSource.Internal -> path.toInternalFile()
+ FileSource.External -> path.toExternalFile()
+ FileSource.Classpath -> path.toClasspathFile()
+ FileSource.Local -> path.toLocalFile()
+ FileSource.Absolute -> path.toAbsoluteFile()
+ }.apply { customOptions() }
+ /**
+ * Represents the libGDX file system used to resolve a path.
+ *
+ * See: https://libgdx.com/wiki/files/file-handling
+ */
enum class FileSource {
+
+ /** Files bundled inside the application (assets folder). */
Internal,
+
+ /** User-accessible files outside the application (platform-dependent). */
External,
+
+ /** Files located on the application classpath (typically inside JARs). */
Classpath,
+
+ /** Files stored relative to the application's working directory. */
Local,
+
+ /** Files referenced using an absolute system path. */
Absolute,
}
}
diff --git a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/parsers/JsonParser.kt b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/parsers/JsonParser.kt
index ed21777..046abf3 100644
--- a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/parsers/JsonParser.kt
+++ b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/parsers/JsonParser.kt
@@ -7,33 +7,59 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.serializer
/**
- * Utility for serializing and parsing JSON data using kotlinx.serialization.
- * Supports custom serializers modules for polymorphic data.
+ * JSON helper built on top of kotlinx.serialization.
+ *
+ * What this utility does:
+ * - Decodes JSON strings/files into Kotlin types (kotlinx.serialization)
+ * - Encodes Kotlin types into JSON strings/files
+ * - Allows optional [SerializersModule] for polymorphic / custom serializers
+ *
+ * Default JSON configuration (applied to all helpers unless overridden via [config]):
+ * - `classDiscriminator = "type"`: polymorphic payloads use `"type"` to select the subtype
+ * - `ignoreUnknownKeys = true`: forward-compatible parsing (extra fields are ignored)
+ * - pretty printing enabled (useful for config files written back to disk)
+ *
+ * Note:
+ * The [config] lambda is applied last, so callers can override any default.
*/
object JsonParser {
+ /* ============================================================
+ * Decoding
+ * ============================================================ */
+
+ /**
+ * Reads a file and decodes it into [T].
+ *
+ * @param file Source file
+ * @param module Optional serializers module (polymorphism/custom serializers)
+ * @param config Optional JSON builder customization (overrides defaults)
+ */
inline fun fromFile(
file: FileHandle,
module: SerializersModule? = null,
noinline config: JsonBuilder.() -> Unit = {},
): T = fromString(file.readString(), module, config)
+ /**
+ * Decodes a JSON string into [T].
+ */
inline fun fromString(
jsonString: String,
module: SerializersModule? = null,
noinline config: JsonBuilder.() -> Unit = {},
- ): T {
- val json = Json {
- if (module != null) serializersModule = module
- classDiscriminator = "type"
- ignoreUnknownKeys = true
- prettyPrint = true
- prettyPrintIndent = " "
- config()
- }
- return json.decodeFromString(jsonString)
- }
+ ) = buildJson(module, config).decodeFromString(jsonString)
+ /**
+ * Parses a JSON file into a raw [JsonObject].
+ *
+ * This is useful for:
+ * - inspection
+ * - manual extraction
+ * - patch/transform operations before decoding into a concrete type
+ *
+ * @throws IllegalStateException if the root element is not a JSON object
+ */
fun rawParseFile(
file: FileHandle,
module: SerializersModule? = null,
@@ -43,31 +69,27 @@ object JsonParser {
return fromString(jsonString, module, config).jsonObject
}
- // -------------------------------
- // Serialization
- // -------------------------------
+ /* ============================================================
+ * Encoding
+ * ============================================================ */
/**
- * Serializes a [JsonObject] into a JSON string.
+ * Serializes [obj] into a JSON string.
+ *
+ * @param obj Object to serialize
+ * @param module Optional serializers module (polymorphism/custom serializers)
+ * @param config Optional JSON builder customization (overrides defaults)
*/
inline fun toString(
obj: T,
module: SerializersModule? = null,
noinline config: JsonBuilder.() -> Unit = {},
- ): String {
- val json = Json {
- if (module != null) serializersModule = module
- classDiscriminator = "type"
- ignoreUnknownKeys = true
- prettyPrint = true
- prettyPrintIndent = " "
- config()
- }
- return json.encodeToString(obj)
- }
+ ) = buildJson(module, config).encodeToString(obj)
/**
- * Serializes a [JsonObject] and writes it to the given [file].
+ * Serializes [obj] to JSON and writes it to [file].
+ *
+ * Note: `append = false` is intentional to replace file contents.
*/
inline fun toFile(
obj: T,
@@ -79,9 +101,32 @@ object JsonParser {
file.writeString(jsonString, false)
}
+ /* ============================================================
+ * JsonElement helpers
+ * ============================================================ */
+
+ /**
+ * Decodes a [JsonElement] into [T] using the provided [serializer].
+ *
+ * Useful when you already parsed JSON and want to decode only a subtree.
+ */
inline fun decodeJsonElement(serializer: KSerializer = serializer(), element: JsonElement): T =
Json.decodeFromJsonElement(serializer, element)
+ /**
+ * Encodes [data] into a [JsonElement] using the provided [serializer].
+ */
inline fun encodeJsonElement(serializer: KSerializer = serializer(), data: T): JsonElement =
Json.encodeToJsonElement(serializer, data)
+
+ fun buildJson(module: SerializersModule? = null, config: JsonBuilder.() -> Unit = {}) = Json {
+ if (module != null) serializersModule = module
+
+ classDiscriminator = "type"
+ ignoreUnknownKeys = true
+ prettyPrint = true
+ prettyPrintIndent = " "
+
+ config()
+ }
}
diff --git a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/parsers/TomlParser.kt b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/parsers/TomlParser.kt
index 4995b22..fbbec42 100644
--- a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/parsers/TomlParser.kt
+++ b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/parsers/TomlParser.kt
@@ -8,65 +8,81 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.modules.SerializersModule
/**
- * Utility for serializing and parsing TOML data using ktoml + kotlinx.serialization.
- * STRICT (TOML-compliant) by default.
+ * TOML helper built on top of tomlkt + kotlinx.serialization.
+ *
+ * Primary use cases:
+ * - Read engine/app configuration files written in TOML
+ * - Write configuration back to disk in a TOML-friendly format
+ *
+ * Defaults (applied unless overridden via [config]):
+ * - `classDiscriminator = "type"`: polymorphic values use `"type"` to select the subtype
+ * - `ignoreUnknownKeys = true`: forward-compatible parsing (extra fields are ignored)
+ * - `explicitNulls = false`: TOML has no null literal; missing keys are treated as absent/defaults
+ *
+ * Note on "STRICT":
+ * tomlkt parses TOML according to the TOML specification. This parser does not attempt
+ * to accept non-standard TOML extensions by default.
*/
object TomlParser {
+ /* ============================================================
+ * Decoding
+ * ============================================================ */
+
/**
- * Parses a TOML file into an instance of the specified type [T].
+ * Reads a TOML file and decodes it into [T].
+ *
+ * @param file Source file handle
+ * @param module Optional serializers module for polymorphic/custom serializers
+ * @param config Optional tomlkt config customization (applied last; can override defaults)
*/
inline fun fromFile(
file: FileHandle,
module: SerializersModule? = null,
noinline config: TomlConfigBuilder.() -> Unit = {},
- ): T = fromString(file.readString(), module, config)
+ ): T = fromString(file.readString(), module, config)
/**
- * Parses a TOML string into an instance of the specified type [T].
+ * Decodes a TOML string into [T].
*/
inline fun fromString(
tomlString: String,
module: SerializersModule? = null,
noinline config: TomlConfigBuilder.() -> Unit = {},
- ): T {
- val toml = Toml {
- if (module != null) serializersModule = module
- classDiscriminator = "type"
- ignoreUnknownKeys = true
- explicitNulls = false // TOML spec doesn't support null; treat missing keys as null/defaults.
- config()
- }
- return toml.decodeFromString(tomlString)
- }
+ ) = buildToml(module, config).decodeFromString