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 Engine logo +

---- +

+ Canopy is a modular 2D game engine written in Kotlin. +

- - Canopy Engine logo - + 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**. -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/canopyengine/canopy#license) -![Version](https://img.shields.io/badge/version-0.0.1-red.svg) +--- + +# Example Scene + +Scenes are built using a Kotlin DSL. + +```kotlin +EmptyNode("root") { + + Player { + behavior(PlayerController()) + behavior(Move()) + } + + Enemy() -[//]: # ([![Crates.io](https://img.shields.io/crates/v/bevy.svg)](https://crates.io/crates/bevy)) -[//]: # ([![Downloads](https://img.shields.io/crates/d/bevy.svg)](https://crates.io/crates/bevy)) -[//]: # ([![Docs](https://docs.rs/bevy/badge.svg)](https://docs.rs/bevy/latest/bevy/)) -[//]: # ([![CI](https://github.com/bevyengine/bevy/workflows/CI/badge.svg)](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 @@ -

Canopy Engine logo - 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(tomlString) - // ------------------------------- - // Serialization - // ------------------------------- + /* ============================================================ + * Encoding + * ============================================================ */ /** - * Serializes an object of type [T] into a serialized [String]. + * Serializes [obj] into a TOML string. + * + * @param obj Object to serialize + * @param module Optional serializers module for polymorphic/custom serializers + * @param config Optional tomlkt config customization (applied last; can override defaults) */ inline fun toString( obj: T, module: SerializersModule? = null, noinline config: TomlConfigBuilder.() -> Unit = {}, - ): String { - 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.encodeToString(obj) - } + ) = buildToml(module, config).encodeToString(obj) /** - * Serializes an object of type [T] and writes it to the given [file]. + * Serializes [obj] and writes it to [file], replacing existing contents. */ inline fun toFile(obj: T, file: FileHandle, module: SerializersModule? = null) { val tomlString = toString(obj, module) file.writeString(tomlString, false) } + + fun buildToml(module: SerializersModule? = null, config: TomlConfigBuilder.() -> Unit) = Toml { + if (module != null) serializersModule = module + + classDiscriminator = "type" + ignoreUnknownKeys = true + explicitNulls = false + + config() + } } diff --git a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/registry/IdEntry.kt b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/registry/IdEntry.kt index 283c2a5..8643ca7 100644 --- a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/registry/IdEntry.kt +++ b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/registry/IdEntry.kt @@ -1,11 +1,38 @@ package io.canopy.engine.data.core.registry /** - * Generic entry with a domain and entry name - id is generated by appending both + * Represents an entry that can be stored in an [IdRegistry]. + * + * Each entry is identified by a **namespaced ID** composed of: + * + * ``` + * : + * ``` + * + * Example: + * ``` + * canopy:player + * mygame:enemy + * ``` + * + * The **domain** usually identifies the owning system, module, or game, + * while the **name** identifies the specific entry within that domain. + * + * This pattern prevents ID collisions between different modules or libraries. + * * @see IdRegistry */ interface IdEntry { + + /** Namespace or module that owns this entry (e.g. `canopy`, `mygame`). */ val domain: String + + /** Local identifier of the entry inside the domain. */ val name: String - val id: String get() = "$domain:$name" + + /** + * Fully-qualified ID composed of `domain:name`. + */ + val id: String + get() = "$domain:$name" } diff --git a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/registry/IdRegistry.kt b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/registry/IdRegistry.kt index 88fd1f2..4ff9b46 100644 --- a/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/registry/IdRegistry.kt +++ b/engine/data/data-core/src/main/kotlin/io/canopy/engine/data/core/registry/IdRegistry.kt @@ -4,68 +4,122 @@ import com.badlogic.gdx.files.FileHandle import io.canopy.engine.data.core.parsers.JsonParser /** - * Loads defined items from .json files into registry maps. - * Useful for mapping ids into concrete objects + * Simple ID-based registry that loads [IdEntry] items from JSON files. + * + * Use cases: + * - Map stable string IDs (e.g. `"canopy:player"`) to concrete objects + * - Load content definitions from disk (assets/configs/mods) + * + * Loading modes: + * 1) In-memory (tests / programmatic): + * `loadRegistry(listOf(...))` + * + * 2) From disk: + * - if [source] is a file: loads that file (if it ends with `.json`) + * - if [source] is a directory: recursively loads all `.json` files inside it + * + * Notes: + * - IDs must be unique across all loaded items. + * - This registry does not currently support hot-reload or removal; it only adds. */ class IdRegistry( - val source: FileHandle? = null, // null for tests + /** File or directory to load from. Null is allowed for tests/programmatic use. */ + val source: FileHandle? = null, ) { + /** - * Maps a class / subclass of T to an id-map + * Registry storage: maps `id -> entry`. + * + * Example key: `"canopy:player"` */ val map: MutableMap = mutableMapOf() init { + // Fail fast if a source was supplied but doesn't exist. if (source != null) check(source.exists()) { "'${source.path()}' not found!" } } + /** Number of registered entries. */ fun nEntries(): Int = map.size /** - * Loads registry - either by passing a list of items, - * or passing **null** if to load from 'source' field of the repository + * Loads entries into the registry. + * + * @param registryItems If provided, adds those items directly (useful for tests). + * If null, loads from [source] (which must not be null). + * + * @throws IllegalStateException if registryItems is null and [source] is null */ inline fun loadRegistry(registryItems: List? = null) { + // Programmatic load (tests / generated content). if (registryItems != null) { addItemsToRegistry(registryItems) return } + // File-based load. check(source != null) { "No registry items passed - source shouldn't be null!" } - val jsonFiles = if (source.isDirectory) collectJsonFiles(source) else listOf(source) + + val jsonFiles = + if (source.isDirectory) { + collectJsonFiles(source) + } else { + listOf(source) + } jsonFiles .filter { it.extension() == "json" } .forEach { file -> - val items: List = JsonParser.fromFile(file) // R is reified + // R is reified so JsonParser can decode List. + val items: List = JsonParser.fromFile(file) addItemsToRegistry(items) } } + /** + * Adds items to the registry, enforcing uniqueness by [IdEntry.id]. + * + * @throws IllegalArgumentException if any duplicate ID is found + */ fun addItemsToRegistry(items: List) { items.forEach { item -> - require(map.putIfAbsent(item.id, item) == null) { "Item with duplicate id found: ${item.id}" } + require(map.putIfAbsent(item.id, item) == null) { + "Item with duplicate id found: ${item.id}" + } } } + /** + * Recursively collects all `.json` files under [dir]. + * + * Note: + * - This scans subdirectories depth-first. + */ fun collectJsonFiles(dir: FileHandle): List = dir.list()?.flatMap { file -> when { file.isDirectory -> collectJsonFiles(file) - - // recurse into subfolders file.extension() == "json" -> listOf(file) - else -> emptyList() } } ?: emptyList() + /** + * Resolves a list of IDs into a list of registry items, optionally applying a mutation block. + * + * @param ids IDs to resolve. + * @param updateHandler Optional callback applied to each resolved item. + * + * @throws IllegalArgumentException if any ID is missing or has the wrong runtime type. + */ inline fun mapIds(ids: List, updateHandler: R.() -> Unit = {}): List { if (ids.isEmpty()) return emptyList() + return ids.map { id -> - val item = - map[id] as? R ?: throw IllegalArgumentException( + val item = map[id] as? R + ?: throw IllegalArgumentException( "Id '$id' not found in registry '${R::class.simpleName}'" ) + item.apply(updateHandler) } } diff --git a/engine/data/data-core/src/test/kotlin/io/canopy/engine/data/core/parsers/JsonParserTests.kt b/engine/data/data-core/src/test/kotlin/io/canopy/engine/data/core/parsers/JsonParserTests.kt index 8c77814..2d9f3c3 100644 --- a/engine/data/data-core/src/test/kotlin/io/canopy/engine/data/core/parsers/JsonParserTests.kt +++ b/engine/data/data-core/src/test/kotlin/io/canopy/engine/data/core/parsers/JsonParserTests.kt @@ -11,7 +11,17 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +/** + * Tests for [JsonParser]. + * + * JsonParser defaults (important for these tests): + * - ignoreUnknownKeys = true + * - classDiscriminator = "type" for polymorphic decoding + */ class JsonParserTests { + + // --- Test fixtures ------------------------------------------------------- + @Serializable data class SimpleData(val id: Int, val name: String) @@ -26,81 +36,102 @@ class JsonParserTests { @SerialName("ImplementationB") data class ImplementationB(val valueB: Int) : BaseType + // --- Simple decoding ----------------------------------------------------- + @Nested - @DisplayName("Simple tests for JsonParser") + @DisplayName("Simple decoding") inner class SimpleJsonParserTests { + @Test fun `should parse JSON string into data class`() { + // Verifies basic decode from a JSON object into a Kotlin @Serializable type. val jsonString = """{"id":1,"name":"Test"}""" - val parsedData = JsonParser.fromString(jsonString) - assert(parsedData.id == 1) - assert(parsedData.name == "Test") + + val parsed = JsonParser.fromString(jsonString) + + assertEquals(1, parsed.id) + assertEquals("Test", parsed.name) } @Test - fun `should parse JSON string with unknown keys`() { + fun `should ignore unknown keys by default`() { + // JsonParser is configured with ignoreUnknownKeys = true. val jsonString = """{"id":2,"name":"Unknown","extraField":"ignored"}""" - val parsedData = JsonParser.fromString(jsonString) - assert(parsedData.id == 2) - assert(parsedData.name == "Unknown") + + val parsed = JsonParser.fromString(jsonString) + + assertEquals(2, parsed.id) + assertEquals("Unknown", parsed.name) } @Test fun `should parse list of data classes from JSON string`() { + // Verifies decoding generic collections works (reified type). val jsonString = """[{"id":3,"name":"Item1"},{"id":4,"name":"Item2"}]""" - val parsedData = JsonParser.fromString>(jsonString) - assert(parsedData.size == 2) - assert(parsedData[0].id == 3 && parsedData[0].name == "Item1") - assert(parsedData[1].id == 4 && parsedData[1].name == "Item2") + + val parsed = JsonParser.fromString>(jsonString) + + assertEquals(2, parsed.size) + + assertEquals(3, parsed[0].id) + assertEquals("Item1", parsed[0].name) + + assertEquals(4, parsed[1].id) + assertEquals("Item2", parsed[1].name) } } + // --- Polymorphic decoding ------------------------------------------------ + @Nested - @DisplayName("Parsing with polymorphic types") + @DisplayName("Polymorphic decoding") inner class PolymorphicJsonParserTests { - val module = - SerializersModule { - polymorphic(BaseType::class) { - subclass(ImplementationA::class) - subclass(ImplementationB::class) - } + + /** + * Polymorphic module used for these tests. + * + * JsonParser uses `classDiscriminator = "type"`, + * so the JSON must contain `"type": ""`. + */ + private val module = SerializersModule { + polymorphic(BaseType::class) { + subclass(ImplementationA::class) + subclass(ImplementationB::class) } + } @Test fun `should parse polymorphic JSON string into correct subclass`() { - val jsonStringA = - """ + val jsonString = """ { - "type": "ImplementationA", - "valueA": "Hello" + "type": "ImplementationA", + "valueA": "Hello" } - """.trimIndent() - val parsedA = JsonParser.fromString(jsonStringA, module) - assert(parsedA is ImplementationA) - assert((parsedA as ImplementationA).valueA == "Hello") + """.trimIndent() + + val parsed = JsonParser.fromString(jsonString, module) + + val a = assertIs(parsed) + assertEquals("Hello", a.valueA) } @Test fun `should parse list of polymorphic types from JSON string`() { - val jsonString = """[ - { - "type": "ImplementationA", - "valueA": "First" - }, - { - "type": "ImplementationB", - "valueB": 42 - } - ]""" - val parsedList = JsonParser.fromString>(jsonString, module) - assertEquals(2, parsedList.size) + val jsonString = """ + [ + { "type": "ImplementationA", "valueA": "First" }, + { "type": "ImplementationB", "valueB": 42 } + ] + """.trimIndent() + + val parsed = JsonParser.fromString>(jsonString, module) + + assertEquals(2, parsed.size) - val itemA = parsedList[0] - assertIs(itemA) + val itemA = assertIs(parsed[0]) assertEquals("First", itemA.valueA) - val itemB = parsedList[1] - assertIs(itemB) + val itemB = assertIs(parsed[1]) assertEquals(42, itemB.valueB) } } diff --git a/engine/data/data-core/src/test/kotlin/io/canopy/engine/data/core/parsers/TomlParserTests.kt b/engine/data/data-core/src/test/kotlin/io/canopy/engine/data/core/parsers/TomlParserTests.kt index 47e5098..cf6302c 100644 --- a/engine/data/data-core/src/test/kotlin/io/canopy/engine/data/core/parsers/TomlParserTests.kt +++ b/engine/data/data-core/src/test/kotlin/io/canopy/engine/data/core/parsers/TomlParserTests.kt @@ -2,13 +2,26 @@ package io.canopy.engine.data.core.parsers import kotlinx.serialization.Serializable import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +/** + * Tests for [TomlParser]. + * + * Important constraints: + * - TOML has no `null` literal. Strings like `name = null` are invalid TOML. + * - TomlParser defaults to `explicitNulls = false`, which means: + * - decoding: missing keys map to defaults / nullables + * - encoding: nullable fields that are null are typically *omitted*, not written as `null` + */ class TomlParserTests { + // --- Fixtures ----------------------------------------------------------- + @Serializable data class SimpleConfig(val name: String, val enabled: Boolean = true, val retries: Int = 0) @@ -24,16 +37,19 @@ class TomlParserTests { data class Server(val host: String, val port: Int) } + // --- Strict / default behavior ----------------------------------------- + @org.junit.jupiter.api.Nested - @DisplayName("Strict TOML parsing tests") - inner class StrictTomlParsingTests { + @DisplayName("Default TOML parsing behavior") + inner class DefaultTomlParsingTests { @Test - fun `parseString - strict - parses valid toml`() { + fun `fromString parses valid toml`() { + // Verifies basic decoding. val toml = """ - name = "canopy" - enabled = true - retries = 3 + name = "canopy" + enabled = true + retries = 3 """.trimIndent() val cfg = TomlParser.fromString(toml) @@ -44,10 +60,9 @@ class TomlParserTests { } @Test - fun `parseString - strict - default values are applied`() { - val toml = """ - name = "canopy" - """.trimIndent() + fun `fromString applies default values when keys are missing`() { + // Missing keys should map to default constructor values. + val toml = """name = "canopy"""" val cfg = TomlParser.fromString(toml) @@ -57,25 +72,23 @@ class TomlParserTests { } @Test - fun `parseString - strict - rejects null values`() { - // TOML spec doesn't support null; strict/compliant mode should reject it. + fun `fromString rejects invalid toml null literal`() { + // TOML has no null literal; `name = null` should fail parsing/decoding. val toml = """ - name = null - enabled = true - retries = 1 + name = null + enabled = true + retries = 1 """.trimIndent() - // Depending on where it fails, ktoml may throw parsing or decoding exceptions. assertThrows { TomlParser.fromString(toml) } } @Test - fun `parseString - strict - rejects mixed-type arrays`() { - val toml = """ - values = [1, "two", 3] - """.trimIndent() + fun `fromString rejects mixed-type arrays`() { + // TOML arrays must be homogeneous. + val toml = """values = [1, "two", 3]""" assertThrows { TomlParser.fromString(toml) @@ -83,12 +96,13 @@ class TomlParserTests { } @Test - fun `parseString - strict - rejects duplicate keys`() { + fun `fromString rejects duplicate keys`() { + // Duplicate keys should not be allowed in a single table. val toml = """ - name = "a" - name = "b" - enabled = true - retries = 0 + name = "a" + name = "b" + enabled = true + retries = 0 """.trimIndent() assertThrows { @@ -97,11 +111,12 @@ class TomlParserTests { } @Test - fun `parseString - strict - parses nested tables`() { + fun `fromString parses nested tables`() { + // Verifies decoding TOML tables into nested @Serializable structures. val toml = """ - [server] - host = "localhost" - port = 8080 + [server] + host = "localhost" + port = 8080 """.trimIndent() val cfg = TomlParser.fromString(toml) @@ -111,46 +126,57 @@ class TomlParserTests { } @Test - fun `toString - strict - encodes and decodes roundtrip`() { + fun `toString encodes and decodes roundtrip`() { + // Verifies encode -> decode stability. val original = SimpleConfig(name = "canopy", enabled = false, retries = 7) val encoded = TomlParser.toString(original) val decoded = TomlParser.fromString(encoded) assertEquals(original, decoded) - // basic sanity: output should include required fields + + // Sanity check: encoded output includes the important fields. assertTrue(encoded.contains("""name = "canopy"""")) assertTrue(encoded.contains("enabled = false")) assertTrue(encoded.contains("retries = 7")) } @Test - fun `toString - strict - cannot encode data that requires null`() { + fun `toString omits null fields when explicitNulls is false`() { + // With explicitNulls=false, serializers typically omit null fields rather than writing `null`. @Serializable data class NullableField(val maybe: String? = null) - val value = NullableField(null) + val original = NullableField(null) - // In strict/compliant mode, emitting null should fail (TOML has no null). - assertEquals("", TomlParser.toString(value)) + val encoded = TomlParser.toString(original) + + // We should NOT emit `maybe = null` (invalid TOML). + assertFalse(encoded.contains("maybe")) + + // Roundtrip should keep null. + val decoded = TomlParser.fromString(encoded) + assertEquals(original, decoded) } } + // --- "Non-strict" customization ---------------------------------------- + // + // NOTE: TOML itself doesn't support null literals. The safest "lenient" behavior you can test is + // that missing keys can map to nullable properties / defaults. + @org.junit.jupiter.api.Nested - @DisplayName("Non-strict TOML parsing tests") - inner class NonStrictTomlParsingTests { + @DisplayName("Custom config behavior") + inner class CustomTomlParsingTests { @Test - fun `parseString - non-strict - allows null values`() { + fun `missing nullable key decodes as null`() { val toml = """ - name = null - enabled = true - retries = 1 + enabled = true + retries = 1 """.trimIndent() - val cfg = TomlParser.fromString(toml) { - explicitNulls = true // allow nulls in non-strict mode - } + val cfg = TomlParser.fromString(toml) assertEquals(null, cfg.name) assertTrue(cfg.enabled) diff --git a/engine/data/data-saving/src/main/kotlin/io/canopy/engine/data/saving/SaveManager.kt b/engine/data/data-saving/src/main/kotlin/io/canopy/engine/data/saving/SaveManager.kt index d523e0e..e4e9091 100644 --- a/engine/data/data-saving/src/main/kotlin/io/canopy/engine/data/saving/SaveManager.kt +++ b/engine/data/data-saving/src/main/kotlin/io/canopy/engine/data/saving/SaveManager.kt @@ -8,35 +8,84 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject /** - * Responsible for handling data saving and loading. + * Coordinates save/load of game data across multiple independent [SaveModule]s. * - * The structure of data serialization and deserialization are configured through individual modules. + * Concepts: + * - Destination: a named save "channel" (e.g. "profile", "world", "settings"). + * Each destination maps a slot number -> [FileHandle]. + * - Slot: numeric save slot (e.g. 0..N). + * - Module: a pluggable unit that knows how to serialize/deserialize one piece of data. + * + * On-disk format (per destination file): + * ```json + * { + * "": { ...module json... }, + * "": { ...module json... } + * } + * ``` + * + * Notes: + * - If a destination file does not exist, [load] is a no-op. + * - Each module is stored under its own stable [SaveModule.id]. + * - Module iteration order is not relied upon (registry is a map). * * @see SaveModule */ class SaveManager(vararg destinations: Pair FileHandle>) : Manager { + + /** Maps destination name -> slot -> file path resolver. */ private val destinationsMap: MutableMap FileHandle> = mutableMapOf(*destinations) - /** Holds module info, and maps to data to be saved */ - private val dataRegistry: - MutableMap, @Serializable Any>> = + /** + * In-memory store of loaded module data. + * + * destination -> (module -> lastLoadedData) + * + * We store `Any` because each module has its own type. The module's serializer + * determines how it is encoded/decoded. + */ + private val dataRegistry: MutableMap, @Serializable Any>> = mutableMapOf() - // Register new save module + /* ============================================================ + * Module registration + * ============================================================ */ + + /** + * Registers a save module under a destination. + * + * This is typically called by the module itself (or a bootstrapper) during setup. + * We store a placeholder value until the first load/save occurs. + */ internal fun registerSaveModule(destination: String, module: SaveModule<*>) { val registry = dataRegistry.getOrPut(destination) { mutableMapOf() } - registry[module] = Unit // placeholder + registry[module] = Unit // placeholder until a real value is loaded or saved } + /** + * Clears all modules registered under [destination]. + * + * Useful in tests or when rebuilding save pipelines dynamically. + */ fun cleanModules(destination: String) { dataRegistry[destination] = mutableMapOf() } + /* ============================================================ + * Loading + * ============================================================ */ + /** - * Loads data from a given save slot. + * Loads a destination file for the given save [slot] and dispatches the data to modules. * - * Each registered module has its onLoad method called, with the parsed data passed directly. + * Behavior: + * - If destination is unknown, no-op. + * - If the file does not exist, no-op. + * - For each registered module: + * - if the JSON contains `module.id`, decode it using the module serializer + * - store it in memory + * - call [SaveModule.onLoad] */ @Suppress("UNCHECKED_CAST") fun load(destination: String, slot: Int) { @@ -50,8 +99,7 @@ class SaveManager(vararg destinations: Pair FileHandle>) val jsonElement = jsonData[module.id] ?: return@forEach val typedModule = module as SaveModule - val decodedData = - JsonParser.decodeJsonElement(typedModule.serializer, jsonElement) + val decodedData = JsonParser.decodeJsonElement(typedModule.serializer, jsonElement) registry[typedModule] = decodedData typedModule.onLoad(decodedData) @@ -59,48 +107,62 @@ class SaveManager(vararg destinations: Pair FileHandle>) } /** - * Loads specific data - useful for reading data after initial load. + * Returns the last loaded data of type [T] for the given [destination]. + * + * Intended usage: + * - after [load] has already been called + * - to access module data without holding module references + * + * @throws IllegalStateException if the destination has no registry + * @throws NoSuchElementException if no stored entry matches the requested type */ @Suppress("UNCHECKED_CAST") fun loadData(destination: String, clazz: KClass): T { val registry = - dataRegistry[destination] - ?: error("No registry for destination $destination") + dataRegistry[destination] ?: error("No registry for destination $destination") return registry.values.first { it::class == clazz } as T } + /* ============================================================ + * Saving + * ============================================================ */ + /** - * Saves data into a given slot. + * Saves all registered modules for [destination] into the given save [slot]. * - * JSON structure is defined based on registration order. - * - * Data to be saved on each module is defined by the return value of the **onSave** method. + * Each module defines its payload via [SaveModule.onSave]. + * The module serializer is used to encode the payload, stored under [SaveModule.id]. */ fun save(destination: String, slot: Int) { val fileLocation = destinationsMap[destination]?.invoke(slot) ?: return val registry = dataRegistry[destination] ?: return - val jsonMap = - buildMap { - registry.keys.forEach { module -> - @Suppress("UNCHECKED_CAST") - val typedModule = module as SaveModule - val data = typedModule.onSave() - put( - typedModule.id, - JsonParser.encodeJsonElement(typedModule.serializer, data) - ) - } + if (registry.isEmpty()) return + + val jsonMap = buildMap { + registry.keys.forEach { module -> + @Suppress("UNCHECKED_CAST") + val typedModule = module as SaveModule + + val data = typedModule.onSave() + put( + typedModule.id, + JsonParser.encodeJsonElement(typedModule.serializer, data) + ) } + } + JsonParser.toFile(JsonObject(jsonMap), fileLocation) } + /** Saves all destinations for the given slot (e.g. profile + world + settings). */ fun saveAll(slot: Int) { destinationsMap.keys.forEach { save(it, slot) } } + /** Loads all destinations for the given slot. */ fun loadAll(slot: Int) { - destinationsMap.keys.forEach { it -> load(it, slot) } + destinationsMap.keys.forEach { load(it, slot) } } } diff --git a/engine/data/data-saving/src/main/kotlin/io/canopy/engine/data/saving/SaveModule.kt b/engine/data/data-saving/src/main/kotlin/io/canopy/engine/data/saving/SaveModule.kt index 6d9f0e6..f73ba3b 100644 --- a/engine/data/data-saving/src/main/kotlin/io/canopy/engine/data/saving/SaveModule.kt +++ b/engine/data/data-saving/src/main/kotlin/io/canopy/engine/data/saving/SaveModule.kt @@ -4,17 +4,57 @@ import io.canopy.engine.core.managers.ManagersRegistry import io.canopy.engine.core.managers.manager import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer /** - * Represents a module of the persisted data. + * A unit of persisted data handled by [SaveManager]. + * + * Each module is responsible for **one logical piece of save data** and defines: + * - how to serialize/deserialize the data ([serializer]) + * - how to produce the data to persist ([onSave]) + * - how to apply loaded data back into runtime state ([onLoad]) + * + * The module [id] is used as the key in the save file: + * + * ```json + * { + * "": { ...serialized module data... } + * } + * ``` */ interface SaveModule { + + /** Stable identifier used as the JSON key for this module (must be unique per destination). */ val id: String + + /** Serializer used to encode/decode this module's payload. */ val serializer: KSerializer + + /** Called during save to produce the payload that will be written to disk. */ val onSave: () -> T + + /** Called during load with the decoded payload. */ val onLoad: (T) -> Unit } +/** + * Registers a [SaveModule] into the global [SaveManager] (via [ManagersRegistry]). + * + * This helper exists so callers can define save modules inline without creating a named class. + * + * Example: + * ``` + * registerSaveModule( + * destination = "profile", + * id = "player.stats", + * serializer = PlayerStats.serializer(), + * onSave = { snapshotStats() }, + * onLoad = { applyStats(it) } + * ) + * ``` + * + * @throws IllegalStateException if [SaveManager] is not registered in [ManagersRegistry] + */ fun registerSaveModule( destination: String, id: String, @@ -24,21 +64,28 @@ fun registerSaveModule( ) { val saveModule = object : SaveModule { - override val id = id - override val serializer = serializer - override val onSave = onSave - override val onLoad = onLoad + override val id: String = id + override val serializer: KSerializer = serializer + override val onSave: () -> T = onSave + override val onLoad: (T) -> Unit = onLoad } + check(ManagersRegistry.has(SaveManager::class)) { """ - [SAVING] - No save manager found! - - To fix it: register it into the Managers Registry! + No SaveManager found in ManagersRegistry. + To fix it: register SaveManager into the ManagersRegistry before calling registerSaveModule(). """.trimIndent() } + // Delegates storage/dispatch to the SaveManager. manager().registerSaveModule(destination, saveModule) } + +inline fun registerSaveModule( + destination: String, + id: String, + noinline onSave: () -> T, + noinline onLoad: (T) -> Unit = {}, +) = registerSaveModule(destination, id, serializer = serializer(), onSave = onSave, onLoad = onLoad) diff --git a/engine/data/data-saving/src/test/kotlin/io/canopy/engine/data/saving/SaveManagerTests.kt b/engine/data/data-saving/src/test/kotlin/io/canopy/engine/data/saving/SaveManagerTests.kt index c6abd93..7b0e902 100644 --- a/engine/data/data-saving/src/test/kotlin/io/canopy/engine/data/saving/SaveManagerTests.kt +++ b/engine/data/data-saving/src/test/kotlin/io/canopy/engine/data/saving/SaveManagerTests.kt @@ -11,12 +11,23 @@ import kotlinx.serialization.builtins.serializer import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeAll +/** + * Tests for [SaveManager] + [SaveModule] integration. + * + * These tests validate: + * - save() is a no-op when no modules are registered for a destination + * - registered modules are saved and loaded correctly (roundtrip) + */ class SaveManagerTests { + companion object { + private val outputDir = File("src/test/output") + + // Destination "player" writes to src/test/output/test-.json val saveManager = SaveManager( "player" to { slot -> - val file = File("src/test/output/test-$slot.json") + val file = File(outputDir, "test-$slot.json") FileHandle(file) } ) @@ -24,35 +35,43 @@ class SaveManagerTests { @JvmStatic @BeforeAll fun setup() { + // Ensure a clean test directory / files. + outputDir.mkdirs() + for (i in 0..1) { - val file = FileHandle(File("src/test/output/test-$i.json")) + val file = FileHandle(File(outputDir, "test-$i.json")) if (file.exists()) file.delete() } + + // Register SaveManager globally so registerSaveModule(...) can find it. ManagersRegistry.register(saveManager) } } @AfterEach fun cleanup() { + // Important: tests share the same SaveManager instance. + // Clear modules after each test to prevent cross-test interference. manager().cleanModules("player") } @Test - fun `should write empty file`() { - // Act + fun `save should not create a file when no modules are registered`() { + // With no registered modules for "player", save() should be a no-op. saveManager.save("player", 0) - // Assert - val file = FileHandle(File("src/test/output/test-0.json")) + + val file = FileHandle(File(outputDir, "test-0.json")) assertTrue { !file.exists() } } @Test - fun `should write data`() { - // Setup - var intData = 0 + fun `save then load should roundtrip module data`() { + // This test registers two modules, saves them, then loads them back + // and verifies each module's onLoad receives the decoded value. + var intData = 0 registerSaveModule( - "player", + destination = "player", id = "test-int", serializer = Int.serializer(), onSave = { 5 }, @@ -61,16 +80,20 @@ class SaveManagerTests { var stringData = "" registerSaveModule( - "player", + destination = "player", id = "test-string", serializer = String.serializer(), onSave = { "abc" }, onLoad = { stringData = it } ) - // Act + + // Act: write to slot 1 saveManager.save("player", 1) - // Assert + + // Act: read from slot 1 (should invoke onLoad for each registered module) saveManager.load("player", 1) + + // Assert: onLoad callbacks received the persisted values assertEquals(5, intData) assertEquals("abc", stringData) } diff --git a/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/managers/CameraManager.kt b/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/managers/CameraManager.kt index 57ab32f..130875e 100644 --- a/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/managers/CameraManager.kt +++ b/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/managers/CameraManager.kt @@ -1,7 +1,7 @@ package io.canopy.engine.graphics.managers import io.canopy.engine.core.managers.Manager -import io.canopy.engine.core.signals.asNullableSignalVal +import io.canopy.engine.core.reactive.asSignal import io.canopy.engine.graphics.nodes.camera.Camera2D import ktx.log.logger @@ -9,7 +9,7 @@ import ktx.log.logger * Manages cameras configured in a scene */ class CameraManager : Manager { - val activeCamera = null.asNullableSignalVal() + val activeCamera = null.asSignal() private val logger = logger() diff --git a/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/nodes/animation/AnimationPlayer.kt b/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/nodes/animation/AnimationPlayer.kt index 320ffed..edfbec0 100644 --- a/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/nodes/animation/AnimationPlayer.kt +++ b/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/nodes/animation/AnimationPlayer.kt @@ -2,7 +2,7 @@ package io.canopy.engine.graphics.nodes.animation import kotlin.math.abs import io.canopy.engine.core.nodes.core.Node -import io.canopy.engine.core.signals.createSignal +import io.canopy.engine.core.reactive.event import io.canopy.engine.graphics.systems.AnimationSystem import io.canopy.engine.utils.UnstableApi import ktx.log.logger @@ -21,8 +21,8 @@ class AnimationPlayer(name: String, block: AnimationPlayer.() -> Unit = {}) : private var playing = true // Signals - val onAnimationChanged = createSignal() - val onAnimationFinished = createSignal() + val onAnimationChanged = event() + val onAnimationFinished = event() var currentAnimation: Animation? = null private set diff --git a/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/nodes/camera/Camera2D.kt b/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/nodes/camera/Camera2D.kt index 59517d6..225c9ca 100644 --- a/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/nodes/camera/Camera2D.kt +++ b/engine/graphics/src/main/kotlin/io/canopy/engine/graphics/nodes/camera/Camera2D.kt @@ -6,7 +6,7 @@ import com.badlogic.gdx.math.Vector3 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.signals.asSignalVal +import io.canopy.engine.core.reactive.asSignal import io.canopy.engine.graphics.managers.CameraManager class Camera2D( @@ -25,7 +25,7 @@ class Camera2D( ) { val camera = OrthographicCamera() - val active = active.asSignalVal() + val active = active.asSignal() private var isResizing = false diff --git a/engine/input/src/main/kotlin/io/canopy/engine/input/InputData.kt b/engine/input/src/main/kotlin/io/canopy/engine/input/InputData.kt index 095a5d1..348c99d 100644 --- a/engine/input/src/main/kotlin/io/canopy/engine/input/InputData.kt +++ b/engine/input/src/main/kotlin/io/canopy/engine/input/InputData.kt @@ -3,15 +3,63 @@ package io.canopy.engine.input import io.canopy.engine.data.core.registry.IdEntry import kotlinx.serialization.Serializable +/** + * Serializable container representing the full input configuration. + * + * Typically loaded from / saved to a file. + * + * Example JSON structure: + * ``` + * { + * "mappings": [ + * { + * "name": "move_left", + * "binds": [ { "type": "KeyBind", "code": 21 } ] + * } + * ] + * } + * ``` + */ @Serializable -class InputData(val mappings: List) +class InputData( + /** List of action mappings. */ + val mappings: List, +) +/** + * Serializable definition of a single input action. + * + * An action can have multiple binds so that several inputs + * trigger the same gameplay behavior. + * + * Example: + * ``` + * move_left -> [A key, Left Arrow] + * ``` + */ @Serializable -class InputEntry(override val name: String, val binds: List) : IdEntry { - override val domain = "input" +class InputEntry( + /** Action name (e.g. "move_left", "jump", "shoot"). */ + override val name: String, + + /** All physical inputs bound to this action. */ + val binds: List, +) : IdEntry { + + /** Fixed registry domain for input actions. */ + override val domain: String = "input" } +/** + * Converts an [InputMapper] into a serializable [InputData] structure. + * + * Useful when exporting runtime mappings into a configuration file. + */ fun InputMapper.asData(): InputData { - val entries: List = mappings.map { (action, binds) -> InputEntry(action, binds) } + val entries: List = + mappings.map { (action, binds) -> + InputEntry(action, binds) + } + return InputData(entries) } diff --git a/engine/input/src/main/kotlin/io/canopy/engine/input/InputEvent.kt b/engine/input/src/main/kotlin/io/canopy/engine/input/InputEvent.kt index 189cb73..ad60ddb 100644 --- a/engine/input/src/main/kotlin/io/canopy/engine/input/InputEvent.kt +++ b/engine/input/src/main/kotlin/io/canopy/engine/input/InputEvent.kt @@ -11,6 +11,13 @@ sealed class InputEvent(open val action: String, open val state: InputState) { */ internal var isHandled = false + /** + * Helper method + */ + fun consume() { + isHandled = true + } + fun isActionPressed(action: String) = this.action == action && isPressedEvent() fun isActionJustPressed(action: String) = this.action == action && state == InputState.JustPressed diff --git a/engine/input/src/main/kotlin/io/canopy/engine/input/InputMapper.kt b/engine/input/src/main/kotlin/io/canopy/engine/input/InputMapper.kt index 225c4fa..4d829f5 100644 --- a/engine/input/src/main/kotlin/io/canopy/engine/input/InputMapper.kt +++ b/engine/input/src/main/kotlin/io/canopy/engine/input/InputMapper.kt @@ -4,37 +4,106 @@ import com.badlogic.gdx.Input import io.canopy.engine.data.saving.registerSaveModule import ktx.log.logger +/** + * Maintains runtime mappings between **input actions** and **physical input binds**. + * + * Example mapping: + * ``` + * "jump" -> [SPACE, GAMEPAD_A] + * "shoot" -> [LEFT_MOUSE] + * ``` + * + * Responsibilities: + * - Store action β†’ bind relationships + * - Resolve which actions correspond to a given bind + * - Load/save mappings via the engine's save system + * + * Mappings are automatically registered into the save system under the + * `"input"` destination so user keybindings can persist across sessions. + */ class InputMapper { + private val logger = logger() + + /** + * Maps action name β†’ list of binds that trigger it. + * + * Example: + * ``` + * move_left -> [A, LEFT_ARROW] + * ``` + */ internal val mappings: MutableMap> = mutableMapOf() init { clearMappings() + + // Register this mapper with the SaveManager so input mappings can be persisted. registerSaveModule( - "input", - "input", - InputData.serializer(), + destination = "input", + id = "input", + serializer = InputData.serializer(), onSave = { this.asData() }, onLoad = ::loadData ) } + /** + * Loads mappings from serialized [InputData]. + */ private fun loadData(data: InputData) { mappings.clear() - mappings.putAll(data.mappings.associate { it.name to it.binds.toMutableList() }) + + mappings.putAll( + data.mappings.associate { entry -> + entry.name to entry.binds.toMutableList() + } + ) } - fun mapToAction(bind: InputBind): List = mappings.filterValues { bind in it }.keys.toList() + /** + * Returns all actions mapped to a given [bind]. + * + * Example: + * ``` + * mapToAction(SPACE) -> ["jump"] + * ``` + */ + fun mapToAction(bind: InputBind): List = mappings.entries + .filter { bind in it.value } + .map { it.key } + /** + * Clears all registered action mappings. + */ fun clearMappings() { mappings.clear() } + /** + * Maps an action to one or more input binds. + * + * @param action the logical action name (e.g. `"jump"`) + * @param newBinds the physical inputs that trigger the action + * @param replace if true, replaces existing binds for the action; + * otherwise appends to the current list + */ fun mapAction(action: String, newBinds: List, replace: Boolean = true) { - logger.info { "Mapping action [$action] to: ${newBinds.map { Input.Keys.toString(it.code) }}" } + logger.info { + "Mapping action [$action] to: ${newBinds.map { Input.Keys.toString(it.code) }}" + } val binds = mappings.computeIfAbsent(action) { mutableListOf() } + if (replace) binds.clear() + binds += newBinds } + + /** + * Unmaps binds + */ + fun unmapAction(action: String) { + mappings.remove(action) + } } diff --git a/engine/input/src/main/kotlin/io/canopy/engine/input/InputSystem.kt b/engine/input/src/main/kotlin/io/canopy/engine/input/InputSystem.kt index 9f75560..33b1d6f 100644 --- a/engine/input/src/main/kotlin/io/canopy/engine/input/InputSystem.kt +++ b/engine/input/src/main/kotlin/io/canopy/engine/input/InputSystem.kt @@ -2,76 +2,122 @@ package io.canopy.engine.input import com.badlogic.gdx.math.Vector2 import io.canopy.engine.core.managers.Manager -import io.canopy.engine.core.nodes.core.TreeSystem +import io.canopy.engine.core.nodes.TreeSystem import io.canopy.engine.utils.UnstableApi import ktx.log.logger +/** + * Polling-based input system that converts raw device state (keys/mouse buttons) + * into engine-level action events ([InputEvent]). + * + * Responsibilities: + * - Poll all configured [InputBind]s each tick + * - Maintain per-action state transitions (Pressed/JustPressed/JustReleased/Released) + * - Dispatch input events into the scene tree (TODO: [dispatchEvents]) + * - Provide helpers for axis and vector inputs (WASD, arrows, etc.) + * + * Notes: + * - This system currently emits: + * - JustPressed (one-shot) + * - Pressed (continuous, but only after the first tick as Pressed) + * - JustReleased (one-shot) + * + * - Mouse movement / pointer events are not generated here yet. + */ @UnstableApi class InputSystem(vararg pairs: Pair>) : TreeSystem(UpdatePhase.PhysicsPre, 10), Manager { + private val logger = logger() + + /** Runtime mapping of action -> physical binds (e.g. "jump" -> [SPACE]). */ private val mapper = InputMapper() + + /** Last computed state for each action (drives transition detection). */ private val actionsState = mutableMapOf() init { - // Map all input actions at startup - pairs.forEach { (action, binds) -> mapper.mapAction(action, binds) } + // Register the initial action bindings at startup. + // (InputMapper also registers its persistence module in its init.) + pairs.forEach { (action, binds) -> + mapper.mapAction(action, binds) + } } // ------------------------------------------------------------------------ - // Polling per frame + // Polling / state update // ------------------------------------------------------------------------ + override fun afterProcess(delta: Float) { + /** + * Poll each action, compute a state transition, and emit the corresponding event(s). + * + * We do not emit a continuous Pressed event on the same tick as JustPressed, + * to avoid duplicate events for a single press. + */ mapper.mappings.forEach { (action, binds) -> val isPressed = binds.any { it.isBeingPressed() } val prevState = actionsState[action] ?: InputState.Released - // Compute the next state - val nextState = getInputState(action, if (isPressed) InputState.Pressed else InputState.Released) + val nextState = getNextState( + action = action, + rawState = if (isPressed) InputState.Pressed else InputState.Released + ) - // Dispatch events when (nextState) { InputState.JustPressed -> { - dispatchEvents( - listOf(ButtonInputEvent(action, InputState.JustPressed)), - delta - ) + dispatchEvents(listOf(ButtonInputEvent(action, InputState.JustPressed)), delta) } + InputState.Pressed -> { - // Only dispatch if the action was already Pressed (avoid spam for taps) + // Only emit Pressed if we were already pressed last tick (continuous hold). + // This prevents "tap spam" where a single key press generates both + // JustPressed + Pressed in the same frame. if (prevState == InputState.Pressed) { - dispatchEvents( - listOf(ButtonInputEvent(action, InputState.Pressed)), - delta - ) + dispatchEvents(listOf(ButtonInputEvent(action, InputState.Pressed)), delta) } } + InputState.JustReleased -> { - dispatchEvents( - listOf(ButtonInputEvent(action, InputState.JustReleased)), - delta - ) + dispatchEvents(listOf(ButtonInputEvent(action, InputState.JustReleased)), delta) } + else -> Unit } } } // ------------------------------------------------------------------------ - // Compute next state based on previous state and raw input + // State machine // ------------------------------------------------------------------------ - private fun getInputState(action: String, newState: InputState): InputState { + + /** + * Computes the next [InputState] for an action based on: + * - the previous computed state stored in [actionsState] + * - the current raw device state (pressed vs released) + * + * Transition summary: + * - Released -> Pressed => JustPressed + * - Pressed -> Pressed => Pressed + * - Pressed -> Released => JustReleased + * - Released -> Released => Released + */ + private fun getNextState(action: String, rawState: InputState): InputState { val prev = actionsState[action] ?: InputState.Released - val prevIsPressedEvent = prev in listOf(InputState.Pressed, InputState.JustPressed) - val prevIsReleasedEvent = prev in listOf(InputState.Released, InputState.JustReleased) + val prevWasPressed = (prev == InputState.Pressed || prev == InputState.JustPressed) + val prevWasReleased = (prev == InputState.Released || prev == InputState.JustReleased) val next = - when (newState) { - InputState.Pressed if (prevIsReleasedEvent) -> InputState.JustPressed - InputState.Pressed -> InputState.Pressed - InputState.Released if (prevIsPressedEvent) -> InputState.JustReleased + when (rawState) { + InputState.Pressed -> + if (prevWasReleased) InputState.JustPressed else InputState.Pressed + + InputState.Released -> + if (prevWasPressed) InputState.JustReleased else InputState.Released + + // Raw state should only be Pressed/Released in this system. else -> InputState.Released } @@ -80,27 +126,53 @@ class InputSystem(vararg pairs: Pair>) : } // ------------------------------------------------------------------------ - // Dispatch events to the SceneManager + // Dispatch // ------------------------------------------------------------------------ + + /** + * Dispatches events into the scene tree. + * + * Expected behavior (once implemented): + * - deliver events to an input root / focused node + * - stop propagation if event.isHandled becomes true + * - optionally include mouse/pointer position for click events + */ private fun dispatchEvents(events: List, delta: Float) { - // node + // TODO: propagate to nodes (e.g., via SceneManager root traversal + InputListener) + // logger.trace { "Dispatching ${events.size} input events" } } - private fun getState(action: String) = actionsState[action] ?: InputState.Released + // ------------------------------------------------------------------------ + // Query helpers (useful for gameplay code) + // ------------------------------------------------------------------------ + + private fun getState(action: String): InputState = actionsState[action] ?: InputState.Released + /** + * Returns a digital axis value in [-1, 0, 1] based on two opposing actions. + * + * Example: + * - negativeAction = "move_left" + * - positiveAction = "move_right" + */ fun getAxis(negativeAction: String, positiveAction: String): Float { val neg = getState(negativeAction) val pos = getState(positiveAction) - val value = - when { - pos == InputState.Pressed && neg == InputState.Released -> 1f - neg == InputState.Pressed && pos == InputState.Released -> -1f - else -> 0f - } - return value + return when { + pos == InputState.Pressed && neg == InputState.Released -> 1f + neg == InputState.Pressed && pos == InputState.Released -> -1f + else -> 0f + } } + /** + * Returns a 2D input vector (digital) from four directional actions. + * + * Example (WASD): + * - negativeX = "move_left", positiveX = "move_right" + * - negativeY = "move_down", positiveY = "move_up" + */ fun getInputVector(negativeX: String, positiveX: String, negativeY: String, positiveY: String): Vector2 = Vector2( getAxis(negativeX, positiveX), getAxis(negativeY, positiveY) diff --git a/engine/input/src/main/kotlin/io/canopy/engine/input/nodes/InputBehavior.kt b/engine/input/src/main/kotlin/io/canopy/engine/input/nodes/InputBehavior.kt index fe7e00b..dbffd6d 100644 --- a/engine/input/src/main/kotlin/io/canopy/engine/input/nodes/InputBehavior.kt +++ b/engine/input/src/main/kotlin/io/canopy/engine/input/nodes/InputBehavior.kt @@ -1,39 +1,62 @@ package io.canopy.engine.input.nodes -import io.canopy.engine.core.nodes.core.Behavior -import io.canopy.engine.core.nodes.core.Node +import io.canopy.engine.core.nodes.Behavior +import io.canopy.engine.core.nodes.Node import io.canopy.engine.input.InputEvent /** + * Specialized [Behavior] that can react to input events. * + * This is typically used by nodes that want to receive input events + * dispatched by the input system (keyboard, mouse, controller, etc.). + * + * Extend this class when creating reusable input logic, + * or use [inputBehavior] for lightweight inline definitions. */ abstract class InputBehavior>(override val node: N? = null) : Behavior(node) { + // =============================== // INPUT // =============================== - /** Called when an input event occurs on the node */ - open fun onInput(event: InputEvent, delta: Float = 0F) = Unit + /** + * Called when an [InputEvent] occurs on the node. + * + * @param event the input event received (keyboard, mouse, etc.) + * @param delta optional delta time since the last frame (if the input + * system propagates it alongside the event) + */ + open fun onInput(event: InputEvent, delta: Float = 0f) = Unit } /** - * Convenience helper to define behaviors via lambdas instead of subclassing [Behavior]. + * Convenience helper to define [InputBehavior] via lambdas instead of subclassing. + * + * This allows quickly attaching input-driven behavior to a node. * - * Example usage: + * Example: * ``` - * val myBehavior = behavior( - * onEnterTree = { println("Node entered tree!") }, - * onUpdate = { delta -> println("Updating with delta $delta") } + * val moveBehavior = inputBehavior( + * onReady = { println("Player ready!") }, + * onInput = { event, _ -> + * if (event.isPressed("move_left")) { + * position.x -= 10f + * } + * } * ) + * + * playerNode.attachBehavior(moveBehavior) * ``` * - * @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 + * @param N Node type the behavior is attached to + * @param onEnterTree called when the node enters the scene tree + * @param onReady called once the node and its children are initialized + * @param onExitTree called when the node exits the scene tree + * @param onUpdate called every frame + * @param onPhysicsUpdate called on each physics tick + * @param onInput called when an input event is dispatched to the node + * + * @return a factory function that produces an [InputBehavior] instance bound to a node */ fun > inputBehavior( onEnterTree: N.() -> Unit = {}, @@ -44,6 +67,7 @@ fun > inputBehavior( onInput: N.(event: InputEvent, delta: Float) -> Unit = { _, _ -> }, ): (node: N) -> InputBehavior = { node -> object : InputBehavior(node) { + override fun onEnterTree() { onEnterTree(node) } diff --git a/engine/input/src/main/kotlin/io/canopy/engine/input/nodes/InputListener.kt b/engine/input/src/main/kotlin/io/canopy/engine/input/nodes/InputListener.kt index b785c19..5129311 100644 --- a/engine/input/src/main/kotlin/io/canopy/engine/input/nodes/InputListener.kt +++ b/engine/input/src/main/kotlin/io/canopy/engine/input/nodes/InputListener.kt @@ -1,17 +1,47 @@ package io.canopy.engine.input.nodes -import io.canopy.engine.core.nodes.core.Node +import io.canopy.engine.core.nodes.Node import io.canopy.engine.input.InputEvent +/** + * Base helper for propagating input events through a node tree. + * + * Propagation model: + * 1) If the event is already handled, stop immediately. + * 2) Give the current node's [script] a chance to handle it. + * 3) If still not handled, propagate the event to child listeners. + * + * This mirrors common UI/game input bubbling: + * - "handled" short-circuits the rest of the propagation chain + * - children only receive the event if the parent didn't consume it + * + * Notes: + * - [children] is intentionally typed as `Map` so this can wrap different + * node implementations without requiring a strict child type. + * - Only children that are also [InputListener] participate in propagation. + */ abstract class InputListener>( private val children: Map, private val script: InputBehavior?, ) { - open fun nodeInput(event: InputEvent, delta: Float = 0F) { + + /** + * Entry point for input propagation. + * + * @param event input event (may be marked handled by any listener) + * @param delta optional delta time forwarded by the input system + */ + open fun nodeInput(event: InputEvent, delta: Float = 0f) { + // If someone already consumed the event, don't propagate further. if (event.isHandled) return + + // Let this node handle input first. script?.onInput(event, delta) + + // If handled by this node, stop propagation. if (event.isHandled) return - // Propagate ito children + + // Propagate to children (depth-first), but only to children that also implement InputListener. children.values .filterIsInstance>() .forEach { it.nodeInput(event, delta) } diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/bootstrap/CanopyLogging.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/CanopyLogging.kt similarity index 63% rename from engine/logging/src/main/kotlin/io/canopy/engine/logging/bootstrap/CanopyLogging.kt rename to engine/logging/src/main/kotlin/io/canopy/engine/logging/CanopyLogging.kt index 35e6950..c85ccd8 100644 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/bootstrap/CanopyLogging.kt +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/CanopyLogging.kt @@ -1,4 +1,4 @@ -package io.canopy.engine.logging.bootstrap +package io.canopy.engine.logging import kotlin.io.path.createDirectories import kotlin.io.path.exists @@ -20,75 +20,135 @@ import ch.qos.logback.core.rolling.RollingFileAppender import ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy import ch.qos.logback.core.spi.FilterReply import ch.qos.logback.core.util.FileSize -import io.canopy.engine.logging.api.LogContext -import io.canopy.engine.logging.api.LogLevel -import io.canopy.engine.logging.api.Logs import io.canopy.engine.logging.util.ConsoleBanner import net.logstash.logback.encoder.LogstashEncoder import org.slf4j.Logger import org.slf4j.LoggerFactory +/** + * Programmatic logging bootstrap for the Canopy engine. + * + * Why this exists: + * - The engine should be able to start with a working, consistent logging setup + * without relying on an external `logback.xml`. + * - We route "engine-origin" logs separately from "user-origin" logs so users get + * clean output and diagnostics remain available for debugging. + * + * Output layout (per run): + * - Console: user logs only (engine logs are suppressed from console) + * - engine.jsonl: structured JSON logs for the engine (machine readable) + * - engine.log: readable engine logs (human friendly) + * - user.log: readable user logs only (non-engine) + * + * Important: + * - [init] is designed to be called once, as early as possible. + * - If the active SLF4J backend is not Logback, this becomes a no-op (no reset). + */ object CanopyLogging { + /** + * Logging configuration for a single engine run/session. + * + * Naming: + * - runId becomes the directory name under [baseLogDir] + * - engineLoggerPrefix defines what counts as "engine" (everything else is "user") + */ data class Config( val baseLogDir: Path = Path.of(".canopy").resolve("logs"), val runId: String = defaultRunFolderName(), // canopy-YYYY-MM-dd-HH-mm val engineVersion: String = "unknown", + // Console output is intentionally user-focused (engine logs are filtered out) val consoleLevel: LogLevel = LogLevel.INFO, val bannerMode: ConsoleBanner.Mode = ConsoleBanner.Mode.GRADIENT, + // Engine output files can be noisier than console val engineJsonLevel: LogLevel = LogLevel.DEBUG, val engineLogLevel: LogLevel = LogLevel.INFO, val userLogLevel: LogLevel = LogLevel.INFO, + // Rolling policy controls val maxHistoryDays: Int = 7, val maxFileSize: String = "10MB", val totalSizeCap: String = "200MB", - // What counts as "engine origin" + // Defines which logger names are considered "engine-origin" + // e.g. "canopy" means anything starting with "canopy..." is engine val engineLoggerPrefix: String = "canopy", ) + /** + * Default run folder name used on disk. + * + * Note: + * - Avoid `HH:mm` because ':' is not a valid character in Windows filenames. + */ fun defaultRunFolderName(now: ZonedDateTime = ZonedDateTime.now()): String = "canopy-" + DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm").format(now) -// NOTE: HH:mm would break on Windows + /** + * Tracks when [init] completed so we can compute session duration in [end]. + */ private val startedAt = AtomicReference(null) /** - * Call once, as early as possible (before any EngineLogs/app logs). - * If Logback isn't the active backend, it's a no-op. + * Initializes logging for the current run. + * + * Call this once, before any meaningful logging occurs. + * + * Implementation detail: + * - If Logback is present (SLF4J backend is Logback), we reset the context and + * install our appenders programmatically. + * - If not Logback, we return early and do not touch logging configuration. */ fun init(config: Config) { - ConsoleBanner.print( - config.engineVersion, - ConsoleBanner.Mode.GRADIENT - ) + // Print the startup banner early. This is cosmetic, but helps users confirm startup. + ConsoleBanner.print(config.engineVersion, config.bannerMode) + // Only proceed if the backend is Logback; otherwise we leave logging untouched. val context = LoggerFactory.getILoggerFactory() as? LoggerContext ?: return + + // Reset the Logback context so we fully control appenders/levels for this run. context.reset() + // Create per-run directory: // val runDir = config.baseLogDir.resolve(config.runId) if (!runDir.exists()) runDir.createDirectories() + // Global context goes into MDC so it is available on all log lines. + // Scoped context (frame/nodePath, etc.) should be set by callers as needed. LogContext.setGlobal( "runId" to config.runId, "engineVersion" to config.engineVersion ) + /* ============================================================ + * APPENDERS + * ============================================================ + * + * We attach appenders to: + * - Root logger: console + user.log + * - Engine namespace logger (config.engineLoggerPrefix): engine.jsonl + engine.log + * + * Filters ensure: + * - Console receives user logs only + * - user.log receives user logs only + * - engine.* files receive engine logs only + */ + // ---------------------------- - // Console appender (keep yours) + // Console appender (user logs only) // ---------------------------- val consoleAppender = ConsoleAppender().apply { this.context = context name = "CONSOLE" encoder = buildConsoleEncoder(context) - isWithJansi = true + isWithJansi = true // enables ANSI colors when supported by the console - // Log user logs only + // Prevent engine-origin logs from cluttering the console. addFilter(denyEngineFilter(config.engineLoggerPrefix).apply { start() }) + // Apply minimum console severity threshold. addFilter( ThresholdFilter().apply { setLevel(config.consoleLevel.name) @@ -98,7 +158,8 @@ object CanopyLogging { start() } - // Optional: keep your noisy OpenAL filter (unchanged) + // Optional noise filter: suppress known noisy OpenAL logs. + // Keep this close to the console appender to avoid affecting file logs. consoleAppender.addFilter( object : Filter() { override fun decide(event: ILoggingEvent): FilterReply { @@ -112,19 +173,23 @@ object CanopyLogging { ) // ---------------------------- - // ENGINE: engine.jsonl + // Engine structured logs: engine.jsonl (machine readable) // ---------------------------- val engineJsonAppender = RollingFileAppender().apply { this.context = context name = "ENGINE_JSONL" file = runDir.resolve("engine.jsonl").toString() + // LogstashEncoder emits JSON per line (JSONL). encoder = LogstashEncoder().apply { this.context = context start() } + // Accept engine-origin logs only. addFilter(engineOnlyFilter(config.engineLoggerPrefix).apply { start() }) + + // Apply minimum severity threshold for JSON logs. addFilter( ThresholdFilter().apply { setLevel(config.engineJsonLevel.name) @@ -133,6 +198,7 @@ object CanopyLogging { ) } + // Roll policy: rotate daily and by size, gzip old files. engineJsonAppender.rollingPolicy = SizeAndTimeBasedRollingPolicy().apply { this.context = context setParent(engineJsonAppender) @@ -142,11 +208,10 @@ object CanopyLogging { setTotalSizeCap(FileSize.valueOf(config.totalSizeCap)) start() } - engineJsonAppender.start() // ---------------------------- - // ENGINE: engine.log (readable) + // Engine readable logs: engine.log (human friendly) // ---------------------------- val engineLogAppender = RollingFileAppender().apply { this.context = context @@ -161,7 +226,10 @@ object CanopyLogging { start() } + // Accept engine-origin logs only. addFilter(engineOnlyFilter(config.engineLoggerPrefix).apply { start() }) + + // Apply minimum severity threshold for readable engine logs. addFilter( ThresholdFilter().apply { setLevel(config.engineLogLevel.name) @@ -179,11 +247,10 @@ object CanopyLogging { setTotalSizeCap(FileSize.valueOf(config.totalSizeCap)) start() } - engineLogAppender.start() // ---------------------------- - // USER: user.log (readable, non-canopy) + // User logs: user.log (non-engine only) // ---------------------------- val userLogAppender = RollingFileAppender().apply { this.context = context @@ -198,8 +265,10 @@ object CanopyLogging { start() } - // deny canopy.* so user.log is truly user-origin + // Deny engine-origin logs so user.log stays clean. addFilter(denyEngineFilter(config.engineLoggerPrefix).apply { start() }) + + // Apply minimum severity threshold for user.log. addFilter( ThresholdFilter().apply { setLevel(config.userLogLevel.name) @@ -217,30 +286,39 @@ object CanopyLogging { setTotalSizeCap(FileSize.valueOf(config.totalSizeCap)) start() } - userLogAppender.start() - // ---------------------------- - // Root routing - // ---------------------------- - // Root: console + user.log (but user.log filter blocks canopy.*) + /* ============================================================ + * LOGGER ROUTING + * ============================================================ + * + * Root logger: + * - Receives everything, but appenders have filters. + * - Console + user.log are attached here. + * + * Engine namespace logger (e.g. "canopy"): + * - Receives engine-origin logs and attaches engine files. + * - isAdditive=true means logs still propagate to root, but root appenders + * already deny engine logs via filters (so console/user.log remain clean). + */ + val root = context.getLogger(Logger.ROOT_LOGGER_NAME).apply { level = Level.TRACE addAppender(consoleAppender) addAppender(userLogAppender) } - // Engine namespace: add engine files context.getLogger(config.engineLoggerPrefix).apply { - isAdditive = true // can be true or false; console filter already blocks engine from printing + isAdditive = true level = Level.TRACE addAppender(engineJsonAppender) addAppender(engineLogAppender) } - // Session header logs (unchanged idea) + // Record session start time for duration computation in [end]. startedAt.set(Instant.now()) + // Emit a session header event into engine logs. Logs.get("canopy.engine.session").info( "event" to "session.start", "schema" to "canopy-log-v1", @@ -250,12 +328,18 @@ object CanopyLogging { "runDir" to runDir.toString() ) { "Session start" } + // Emit a bootstrap event so it's easy to confirm logging configured correctly. Logs.get("canopy.bootstrap.logging").info( "event" to "logging.init", "runDir" to runDir.toString() ) { "Canopy logging initialized" } } + /** + * Emits the session end event. + * + * This does not shut down Logback; it only records the end-of-session marker. + */ fun end(reason: String = "normal", t: Throwable? = null) { val start = startedAt.get() val now = Instant.now() @@ -280,21 +364,33 @@ object CanopyLogging { } } + /** + * Default base log directory (/logs). + */ fun defaultBaseLogDir(baseDir: Path = Path.of(".canopy")): Path = baseDir.resolve("logs") + /** + * Alternative run id format used by some callers (kept for compatibility). + */ fun defaultRunId(): String = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(ZonedDateTime.now()) + /** + * Builds the console encoder and registers any custom conversion words. + * + * We register `mdcx` so console output can show MDC keys *except* some noisy ones, + * while still allowing specific keys to be displayed (e.g. runId). + */ fun buildConsoleEncoder(context: LoggerContext): PatternLayoutEncoder { - // 1) Register conversion word -> converter class + // Register conversion word -> converter class @Suppress("UNCHECKED_CAST") val registry = (context.getObject(CoreConstants.PATTERN_RULE_REGISTRY) as? MutableMap) ?: mutableMapOf().also { context.putObject(CoreConstants.PATTERN_RULE_REGISTRY, it) } - registry["mdcx"] = "io.canopy.engine.logging.bootstrap.MdcExcludeConverter" // <-- your real FQCN + // Custom pattern converter (exclude certain MDC keys) + registry["mdcx"] = "io.canopy.engine.logging.bootstrap.MdcExcludeConverter" // FQCN - // 2) Build encoder return PatternLayoutEncoder().apply { this.context = context pattern = @@ -308,6 +404,10 @@ object CanopyLogging { } } + /** + * Filter that accepts only loggers that start with [prefix]. + * Used to route engine-origin logs into engine files. + */ private fun engineOnlyFilter(prefix: String) = object : Filter() { override fun decide(event: ILoggingEvent): FilterReply { val name = event.loggerName ?: return FilterReply.DENY @@ -315,6 +415,10 @@ object CanopyLogging { } } + /** + * Filter that denies loggers that start with [prefix]. + * Used to keep console and user.log free of engine-origin logs. + */ private fun denyEngineFilter(prefix: String) = object : Filter() { override fun decide(event: ILoggingEvent): FilterReply { val name = event.loggerName ?: return FilterReply.NEUTRAL diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/EngineLogs.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/EngineLogs.kt new file mode 100644 index 0000000..4f93941 --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/EngineLogs.kt @@ -0,0 +1,58 @@ +package io.canopy.engine.logging + +import io.canopy.engine.logging.core.Logger + +/** + * Centralized access point for engine loggers. + * + * All loggers created here live under the `canopy.engine.*` namespace, + * which allows the logging system to: + * + * - Route engine-origin logs to dedicated files (engine.log / engine.jsonl) + * - Filter them out from user-facing console output + * - Keep engine logging consistent across subsystems + * + * Contributors: + * - Add new engine subsystems here instead of creating ad-hoc loggers. + * - Subsystem names become part of the logger category + * (e.g. `canopy.engine.physics`, `canopy.engine.render`). + * + * Example usage: + * + * ``` + * EngineLogs.physics.debug { "Stepping physics simulation" } + * EngineLogs.render.info { "Renderer initialized" } + * ``` + */ +object EngineLogs { + + /** + * Creates a logger for a specific engine subsystem. + * + * @param name subsystem identifier (e.g. "physics", "render", "scene") + */ + fun subsystem(name: String): Logger = Logs.get("canopy.engine.$name") + + /* ------------------------------------------------------------ + * Core engine subsystems + * ------------------------------------------------------------ */ + + val lifecycle: Logger = subsystem("lifecycle") + val node: Logger = subsystem("node") + val physics: Logger = subsystem("physics") + val render: Logger = subsystem("render") + + /* ------------------------------------------------------------ + * Engine infrastructure + * ------------------------------------------------------------ */ + + val system: Logger = subsystem("system") + val managers: Logger = subsystem("managers") + val scene: Logger = subsystem("scene") + + /* ------------------------------------------------------------ + * Application / runtime + * ------------------------------------------------------------ */ + + val app: Logger = subsystem("app") +} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/LogContext.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/LogContext.kt new file mode 100644 index 0000000..618747e --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/LogContext.kt @@ -0,0 +1,72 @@ +package io.canopy.engine.logging + +import java.util.concurrent.atomic.AtomicReference +import io.canopy.engine.logging.util.withTemporaryMdcContext + +/** + * Logging context backed by MDC (Mapped Diagnostic Context). + * + * MDC is best used for *context that should automatically appear on many log lines*, + * typically via the logging pattern or encoder configuration. + * + * We split context into two categories: + * + * - Global context: + * Session-wide fields that should exist for the entire engine run + * (e.g. runId, engineVersion). + * + * - Scoped context: + * Short-lived fields applied only while executing a block + * (e.g. frame, nodePath, scene). + * + * Important: + * - Do NOT put per-log-call structured fields into MDC. + * MDC stores values as strings and is thread-local; pushing arbitrary fields into MDC + * makes JSON logs less structured and can create hard-to-debug leakage across calls. + * + * Per-log-call fields should be attached by the logger implementation using the backend’s + * structured logging mechanism (e.g. Logstash StructuredArguments). + */ +object LogContext { + + /** + * Snapshot of global fields. Stored separately (not in MDC) so that: + * - they can be applied consistently by the logging backend + * - we avoid thread-local lifecycle issues for session-wide values + */ + private val global = AtomicReference>(emptyMap()) + + /** + * Replaces the global context entirely. + * + * Use this during initialization when establishing session identity. + */ + fun setGlobal(vararg fields: Pair) { + global.set(fields.toMap()) + } + + /** + * Adds/overrides entries in the global context. + * + * Existing keys are overwritten with the new values. + */ + fun updateGlobal(vararg fields: Pair) { + global.set(global.get() + fields.toMap()) + } + + /** + * Returns the current global context snapshot. + * + * This is intended for logger backends/adapters (e.g. SLF4J) to apply global fields + * to MDC right before emitting a log line. + */ + internal fun globalMdcSnapshot(): Map = global.get() + + /** + * Executes [block] with additional scoped MDC fields. + * + * These fields are applied only for the duration of the block and are always restored, + * even if [block] throws. + */ + fun with(vararg fields: Pair, block: () -> T): T = withTemporaryMdcContext(fields.toMap(), block) +} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/LogLevel.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/LogLevel.kt new file mode 100644 index 0000000..1892851 --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/LogLevel.kt @@ -0,0 +1,26 @@ +package io.canopy.engine.logging + +/** + * Logging levels used by the Canopy logging API. + * + * This enum acts as a framework-agnostic abstraction so the engine + * does not depend directly on a specific logging backend (e.g. SLF4J, + * Logback, Log4j). + * + * Logger implementations are responsible for mapping these levels + * to the equivalent levels of the underlying logging system. + * + * Typical mapping (SLF4J/Logback): + * - TRACE β†’ trace + * - DEBUG β†’ debug + * - INFO β†’ info + * - WARN β†’ warn + * - ERROR β†’ error + */ +enum class LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR, +} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/Logs.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/Logs.kt new file mode 100644 index 0000000..e897a19 --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/Logs.kt @@ -0,0 +1,75 @@ +package io.canopy.engine.logging + +import io.canopy.engine.logging.core.DefaultProvider +import io.canopy.engine.logging.core.LogProvider +import io.canopy.engine.logging.core.Logger + +/** + * Entry point for obtaining [Logger] instances. + * + * The engine logging API is intentionally decoupled from any concrete + * logging backend (e.g. SLF4J, Logback). This object delegates logger + * creation to a pluggable [LogProvider]. + * + * By default, the engine uses the SLF4J-based provider, but applications + * or tests may replace it with a custom implementation. + * + * Typical usage: + * + * ``` + * private val log = logger() + * + * log.info { "Initialization complete" } + * ``` + */ +object Logs { + + /** + * Active logger provider. + * + * Marked as [Volatile] so updates via [setProvider] are immediately + * visible across threads. + */ + @Volatile + private var provider: LogProvider = DefaultProvider + + /** + * Replaces the current logging provider. + * + * This is primarily intended for: + * - Custom logging integrations + * - Testing environments + * - Alternative backends + * + * Should typically be called during application bootstrap. + */ + fun setProvider(provider: LogProvider) { + this.provider = provider + } + + /** + * Returns a logger associated with the given [name]. + * + * The name typically represents a logging category, often a + * fully-qualified class name. + */ + fun get(name: String): Logger = provider.get(name) + + /** + * Returns a logger using the fully-qualified class name of [T] + * as the logger category. + */ + inline fun of(): Logger = get(T::class.java.name) +} + +/* ------------------------------------------------------------------ + * Convenience helpers + * ------------------------------------------------------------------ + * + * These helpers make it easy to obtain loggers without referencing + * the [Logs] object explicitly. + */ + +inline fun logger(): Logger = Logs.of() + +fun logger(name: String): Logger = Logs.get(name) diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/DefaultProvider.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/DefaultProvider.kt deleted file mode 100644 index 4aa6aeb..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/DefaultProvider.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.canopy.engine.logging.api - -import io.canopy.engine.logging.impl.Slf4jProvider - -/** - * Default backend used by the engine. - * Kept in api package only as a small indirection. - */ -internal object DefaultProvider : LogProvider by Slf4jProvider diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/LogContext.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/LogContext.kt deleted file mode 100644 index 864900e..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/LogContext.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.canopy.engine.logging.api - -import java.util.concurrent.atomic.AtomicReference - -/** - * Context = metadata attached automatically via MDC. - * - * - Global context: session-wide fields (runId, engineVersion). - * - Scoped context: temporary fields for a block (frame, nodePath, scene). - * - * NOTE: - * Per-log-call "fields" should NOT go into MDC, otherwise everything becomes strings in JSON. - * Per-call fields should be attached using StructuredArguments in the logger implementation. - */ -object LogContext { - private val global = AtomicReference>(emptyMap()) - - fun setGlobal(vararg fields: Pair) { - global.set(fields.toMap()) - } - - fun updateGlobal(vararg fields: Pair) { - global.set(global.get() + fields.toMap()) - } - - /** Used by logger backend to apply global MDC fields. */ - internal fun globalMdcSnapshot(): Map = global.get() - - /** Scoped MDC fields (thread-local) for a block. */ - fun with(vararg fields: Pair, block: () -> T): T = withMdc(fields.toMap(), block) -} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/LogLevel.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/LogLevel.kt deleted file mode 100644 index 2c0996a..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/LogLevel.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.canopy.engine.logging.api - -enum class LogLevel { TRACE, DEBUG, INFO, WARN, ERROR } diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/LogProvider.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/LogProvider.kt deleted file mode 100644 index 06520e3..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/LogProvider.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.canopy.engine.logging.api - -/** - * Pluggable provider for creating Logger instances. - * Keeps your API independent of SLF4J/Logback. - */ -fun interface LogProvider { - fun get(name: String): Logger -} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/Logger.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/Logger.kt deleted file mode 100644 index 5ce48aa..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/Logger.kt +++ /dev/null @@ -1,36 +0,0 @@ -package io.canopy.engine.logging.api - -interface Logger { - fun isTraceEnabled(): Boolean - fun isDebugEnabled(): Boolean - fun isInfoEnabled(): Boolean - fun isWarnEnabled(): Boolean - fun isErrorEnabled(): Boolean - - fun log(level: LogLevel, t: Throwable? = null, vararg fields: Pair, msg: () -> String) - - fun trace(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = - log(LogLevel.TRACE, t, fields = fields, msg) - - fun trace(vararg fields: Pair, msg: () -> String) = log(LogLevel.TRACE, null, fields = fields, msg) - - fun debug(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = - log(LogLevel.DEBUG, t, fields = fields, msg) - - fun debug(vararg fields: Pair, msg: () -> String) = log(LogLevel.DEBUG, null, fields = fields, msg) - - fun info(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = - log(LogLevel.INFO, t, fields = fields, msg) - - fun info(vararg fields: Pair, msg: () -> String) = log(LogLevel.INFO, null, fields = fields, msg) - - fun warn(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = - log(LogLevel.WARN, t, fields = fields, msg) - - fun warn(vararg fields: Pair, msg: () -> String) = log(LogLevel.WARN, null, fields = fields, msg) - - fun error(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = - log(LogLevel.ERROR, t, fields = fields, msg) - - fun error(vararg fields: Pair, msg: () -> String) = log(LogLevel.ERROR, null, fields = fields, msg) -} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/Logs.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/Logs.kt deleted file mode 100644 index d7d0edc..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/Logs.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.canopy.engine.logging.api - -/** - * Logs = entrypoint for obtaining Logger instances. - * - * Uses a pluggable provider so the API does not depend on SLF4J directly. - */ -object Logs { - @Volatile private var provider: LogProvider = DefaultProvider - - fun setProvider(provider: LogProvider) { - this.provider = provider - } - - fun get(name: String): Logger = provider.get(name) - - inline fun of(): Logger = get(T::class.java.name) -} - -inline fun logger(): Logger = Logs.of() diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/Mdc.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/Mdc.kt deleted file mode 100644 index 7b97981..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/api/Mdc.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.canopy.engine.logging.api - -import org.slf4j.MDC - -internal inline fun withMdc(fields: Map, block: () -> T): T { - if (fields.isEmpty()) return block() - - val old = HashMap(fields.size) - try { - for ((k, v) in fields) { - old[k] = MDC.get(k) - if (v == null) MDC.remove(k) else MDC.put(k, v.toString()) - } - return block() - } finally { - for ((k, prev) in old) { - if (prev == null) MDC.remove(k) else MDC.put(k, prev) - } - } -} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/bootstrap/MdcExcludeConverter.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/bootstrap/MdcExcludeConverter.kt deleted file mode 100644 index dfbc105..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/bootstrap/MdcExcludeConverter.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.canopy.engine.logging.bootstrap - -import ch.qos.logback.classic.pattern.ClassicConverter -import ch.qos.logback.classic.spi.ILoggingEvent - -class MdcExcludeConverter : ClassicConverter() { - - private val cyan = "\u001B[36m" - private val reset = "\u001B[0m" - - override fun convert(event: ILoggingEvent): String { - val exclude = (firstOption ?: "") - .split(',') - .map { it.trim() } - .filter { it.isNotEmpty() } - .toSet() - - val mdc = event.mdcPropertyMap ?: return "" - val extras = mdc.entries - .asSequence() - .filter { (k, v) -> k !in exclude && !v.isNullOrBlank() } - .sortedBy { it.key } - .toList() - - if (extras.isEmpty()) return "" - - return extras.joinToString(" ") { (k, v) -> - "[$cyan$k$reset=$v]" - } - } -} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/core/DefaultProvider.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/core/DefaultProvider.kt new file mode 100644 index 0000000..a84d3f0 --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/core/DefaultProvider.kt @@ -0,0 +1,22 @@ +package io.canopy.engine.logging.core + +import io.canopy.engine.logging.slf4j.Slf4jProvider + +/** + * Default [LogProvider] used by the engine. + * + * This object acts as a small indirection layer between the engine core + * and the concrete logging implementation (currently SLF4J). + * + * The goal is to: + * - Avoid directly coupling the core module to a specific logging backend + * - Allow the logging provider to be swapped or extended in the future + * - Keep the engine API stable while delegating implementation details + * + * All logging calls from the engine resolve through this provider. + * + * Implementation note: + * This uses Kotlin delegation so all [LogProvider] behavior is delegated + * to [Slf4jProvider]. + */ +internal object DefaultProvider : LogProvider by Slf4jProvider diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/core/LogProvider.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/core/LogProvider.kt new file mode 100644 index 0000000..4f5e1f5 --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/core/LogProvider.kt @@ -0,0 +1,28 @@ +package io.canopy.engine.logging.core + +/** + * Factory interface responsible for creating [Logger] instances. + * + * This abstraction allows the engine to remain independent of any + * specific logging implementation (such as SLF4J or Logback). + * + * Concrete providers are responsible for adapting the engine's + * [Logger] interface to a particular logging backend. + * + * Example implementations: + * - SLF4J provider + * - Test / in-memory logger provider + * - Custom logging adapters + * + * The [name] typically represents the logger category, usually the + * fully-qualified class name requesting the logger. + */ +fun interface LogProvider { + + /** + * Returns a logger associated with the given [name]. + * + * @param name the logger category, usually a class or component name + */ + fun get(name: String): Logger +} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/core/Logger.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/core/Logger.kt new file mode 100644 index 0000000..354ce6d --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/core/Logger.kt @@ -0,0 +1,95 @@ +package io.canopy.engine.logging.core + +import io.canopy.engine.logging.LogLevel + +/** + * Core logging abstraction used by the engine. + * + * Implementations of this interface provide the bridge between the engine's + * logging API and a concrete logging backend (e.g. SLF4J). + * + * Design goals: + * - Keep the engine independent of a specific logging framework + * - Support structured logging through key/value fields + * - Avoid unnecessary allocations via lazy message evaluation + * + * Implementations are responsible for mapping these calls to the underlying + * logging system. + */ +interface Logger { + + /** + * Indicates whether TRACE level logging is enabled. + */ + fun isTraceEnabled(): Boolean + + /** + * Indicates whether DEBUG level logging is enabled. + */ + fun isDebugEnabled(): Boolean + + /** + * Indicates whether INFO level logging is enabled. + */ + fun isInfoEnabled(): Boolean + + /** + * Indicates whether WARN level logging is enabled. + */ + fun isWarnEnabled(): Boolean + + /** + * Indicates whether ERROR level logging is enabled. + */ + fun isErrorEnabled(): Boolean + + /** + * Core logging function used by all convenience methods. + * + * @param level log severity + * @param t optional throwable associated with the log entry + * @param fields structured key/value pairs attached to the log entry + * @param msg lazy message supplier to avoid unnecessary string construction + * + * Implementations should: + * - Check whether the level is enabled + * - Attach the structured fields if supported + * - Evaluate [msg] only when the log will actually be emitted + */ + fun log(level: LogLevel, t: Throwable? = null, vararg fields: Pair, msg: () -> String) + + /* ---------- TRACE ---------- */ + + fun trace(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = + log(LogLevel.TRACE, t, fields = fields, msg) + + fun trace(vararg fields: Pair, msg: () -> String) = log(LogLevel.TRACE, null, fields = fields, msg) + + /* ---------- DEBUG ---------- */ + + fun debug(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = + log(LogLevel.DEBUG, t, fields = fields, msg) + + fun debug(vararg fields: Pair, msg: () -> String) = log(LogLevel.DEBUG, null, fields = fields, msg) + + /* ---------- INFO ---------- */ + + fun info(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = + log(LogLevel.INFO, t, fields = fields, msg) + + fun info(vararg fields: Pair, msg: () -> String) = log(LogLevel.INFO, null, fields = fields, msg) + + /* ---------- WARN ---------- */ + + fun warn(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = + log(LogLevel.WARN, t, fields = fields, msg) + + fun warn(vararg fields: Pair, msg: () -> String) = log(LogLevel.WARN, null, fields = fields, msg) + + /* ---------- ERROR ---------- */ + + fun error(t: Throwable? = null, vararg fields: Pair, msg: () -> String) = + log(LogLevel.ERROR, t, fields = fields, msg) + + fun error(vararg fields: Pair, msg: () -> String) = log(LogLevel.ERROR, null, fields = fields, msg) +} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/engine/EngineLogs.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/engine/EngineLogs.kt deleted file mode 100644 index 65e14bf..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/engine/EngineLogs.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.canopy.engine.logging.engine - -import io.canopy.engine.logging.api.Logger -import io.canopy.engine.logging.api.Logs - -object EngineLogs { - fun subsystem(name: String): Logger = Logs.get("canopy.engine.$name") - - val lifecycle: Logger = subsystem("lifecycle") - val node: Logger = subsystem("node") - val physics: Logger = subsystem("physics") - val render: Logger = subsystem("render") - - // βœ… new - val system: Logger = subsystem("system") - val managers: Logger = subsystem("managers") - val scene: Logger = subsystem("scene") - - val app: Logger = subsystem("app") -} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/impl/Slf4jLogger.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/impl/Slf4jLogger.kt deleted file mode 100644 index e43bd80..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/impl/Slf4jLogger.kt +++ /dev/null @@ -1,88 +0,0 @@ -package io.canopy.engine.logging.impl - -import io.canopy.engine.logging.api.LogContext -import io.canopy.engine.logging.api.LogLevel -import io.canopy.engine.logging.api.Logger -import io.canopy.engine.logging.api.withMdc -import net.logstash.logback.argument.StructuredArguments.entries -import org.slf4j.Logger as Slf4j - -class Slf4jLogger(private val delegate: Slf4j) : Logger { - override fun isTraceEnabled(): Boolean = delegate.isTraceEnabled - override fun isDebugEnabled(): Boolean = delegate.isDebugEnabled - override fun isInfoEnabled(): Boolean = delegate.isInfoEnabled - override fun isWarnEnabled(): Boolean = delegate.isWarnEnabled - override fun isErrorEnabled(): Boolean = delegate.isErrorEnabled - - override fun log(level: LogLevel, t: Throwable?, vararg fields: Pair, msg: () -> String) { - val enabled = when (level) { - LogLevel.TRACE -> delegate.isTraceEnabled - LogLevel.DEBUG -> delegate.isDebugEnabled - LogLevel.INFO -> delegate.isInfoEnabled - LogLevel.WARN -> delegate.isWarnEnabled - LogLevel.ERROR -> delegate.isErrorEnabled - } - if (!enabled) return - - val message = msg() - - // βœ… Only global context goes here (strings ok: runId, engineVersion). - // βœ… Scoped context (frame/nodePath) is already in MDC if the caller used LogContext.with(...) - withMdc(LogContext.globalMdcSnapshot()) { - val structured = if (fields.isEmpty()) null else entries(mapOf(*fields)) - - when (level) { - LogLevel.TRACE -> logTrace(message, structured, t) - LogLevel.DEBUG -> logDebug(message, structured, t) - LogLevel.INFO -> logInfo(message, structured, t) - LogLevel.WARN -> logWarn(message, structured, t) - LogLevel.ERROR -> logError(message, structured, t) - } - } - } - - private fun logTrace(message: String, structured: Any?, t: Throwable?) { - when { - structured != null && t != null -> delegate.trace(message, structured, t) - structured != null -> delegate.trace(message, structured) - t != null -> delegate.trace(message, t) - else -> delegate.trace(message) - } - } - - private fun logDebug(message: String, structured: Any?, t: Throwable?) { - when { - structured != null && t != null -> delegate.debug(message, structured, t) - structured != null -> delegate.debug(message, structured) - t != null -> delegate.debug(message, t) - else -> delegate.debug(message) - } - } - - private fun logInfo(message: String, structured: Any?, t: Throwable?) { - when { - structured != null && t != null -> delegate.info(message, structured, t) - structured != null -> delegate.info(message, structured) - t != null -> delegate.info(message, t) - else -> delegate.info(message) - } - } - - private fun logWarn(message: String, structured: Any?, t: Throwable?) { - when { - structured != null && t != null -> delegate.warn(message, structured, t) - structured != null -> delegate.warn(message, structured) - t != null -> delegate.warn(message, t) - else -> delegate.warn(message) - } - } - - private fun logError(message: String, structured: Any?, t: Throwable?) { - when { - structured != null && t != null -> delegate.error(message, structured, t) - structured != null -> delegate.error(message, structured) - t != null -> delegate.error(message, t) - else -> delegate.error(message) - } - } -} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/impl/Slf4jProvider.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/impl/Slf4jProvider.kt deleted file mode 100644 index a3a5613..0000000 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/impl/Slf4jProvider.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.canopy.engine.logging.impl - -import io.canopy.engine.logging.api.LogProvider -import io.canopy.engine.logging.api.Logger -import org.slf4j.LoggerFactory - -internal object Slf4jProvider : LogProvider { - override fun get(name: String): Logger = Slf4jLogger(LoggerFactory.getLogger(name)) -} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/slf4j/Slf4jLogger.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/slf4j/Slf4jLogger.kt new file mode 100644 index 0000000..2fb91e5 --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/slf4j/Slf4jLogger.kt @@ -0,0 +1,105 @@ +package io.canopy.engine.logging.slf4j + +import io.canopy.engine.logging.LogContext +import io.canopy.engine.logging.LogLevel +import io.canopy.engine.logging.core.Logger +import io.canopy.engine.logging.util.withTemporaryMdcContext // <-- rename import if you applied the earlier change +import net.logstash.logback.argument.StructuredArguments.entries +import org.slf4j.Logger as Slf4j + +/** + * SLF4J-backed implementation of the engine [Logger]. + * + * Responsibilities: + * - Perform level checks before doing any work (avoids allocations) + * - Convert engine "structured fields" into Logstash Logback arguments + * - Ensure *global* engine context is present in MDC for all log lines + * + * MDC vs structured fields: + * - MDC is used for contextual data that should automatically appear on every log line + * (and may be included by the log pattern), e.g. runId, engineVersion. + * - Structured fields are per-log-entry key/value pairs passed explicitly to the backend. + * + * Note: + * Scoped context (like frame/nodePath) should already be in MDC when callers use + * `LogContext.with(...)` or similar scoping utilities. + */ +class Slf4jLogger(private val delegate: Slf4j) : Logger { + + override fun isTraceEnabled(): Boolean = delegate.isTraceEnabled + override fun isDebugEnabled(): Boolean = delegate.isDebugEnabled + override fun isInfoEnabled(): Boolean = delegate.isInfoEnabled + override fun isWarnEnabled(): Boolean = delegate.isWarnEnabled + override fun isErrorEnabled(): Boolean = delegate.isErrorEnabled + + override fun log(level: LogLevel, t: Throwable?, vararg fields: Pair, msg: () -> String) { + // Fast path: do not allocate message/fields if the level is disabled. + if (!isEnabled(level)) return + + val message = msg() + + // Only the *global* context is applied here. + // Scoped/ephemeral context should be applied by the caller via LogContext scoping APIs. + withTemporaryMdcContext(LogContext.globalMdcSnapshot()) { + val structuredArgs = fields + .takeIf { it.isNotEmpty() } + ?.let { entries(mapOf(*it)) } + + emit(level, message, structuredArgs, t) + } + } + + private fun isEnabled(level: LogLevel): Boolean = when (level) { + LogLevel.TRACE -> delegate.isTraceEnabled + LogLevel.DEBUG -> delegate.isDebugEnabled + LogLevel.INFO -> delegate.isInfoEnabled + LogLevel.WARN -> delegate.isWarnEnabled + LogLevel.ERROR -> delegate.isErrorEnabled + } + + /** + * Emits the log statement to SLF4J, handling combinations of: + * - structured arguments present/absent + * - throwable present/absent + * + * We keep this logic centralized to avoid duplication across each level. + */ + private fun emit(level: LogLevel, message: String, structured: Any?, t: Throwable?) { + when (level) { + LogLevel.TRACE -> when { + structured != null && t != null -> delegate.trace(message, structured, t) + structured != null -> delegate.trace(message, structured) + t != null -> delegate.trace(message, t) + else -> delegate.trace(message) + } + + LogLevel.DEBUG -> when { + structured != null && t != null -> delegate.debug(message, structured, t) + structured != null -> delegate.debug(message, structured) + t != null -> delegate.debug(message, t) + else -> delegate.debug(message) + } + + LogLevel.INFO -> when { + structured != null && t != null -> delegate.info(message, structured, t) + structured != null -> delegate.info(message, structured) + t != null -> delegate.info(message, t) + else -> delegate.info(message) + } + + LogLevel.WARN -> when { + structured != null && t != null -> delegate.warn(message, structured, t) + structured != null -> delegate.warn(message, structured) + t != null -> delegate.warn(message, t) + else -> delegate.warn(message) + } + + LogLevel.ERROR -> when { + structured != null && t != null -> delegate.error(message, structured, t) + structured != null -> delegate.error(message, structured) + t != null -> delegate.error(message, t) + else -> delegate.error(message) + } + } + } +} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/slf4j/Slf4jProvider.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/slf4j/Slf4jProvider.kt new file mode 100644 index 0000000..97408ec --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/slf4j/Slf4jProvider.kt @@ -0,0 +1,29 @@ +package io.canopy.engine.logging.slf4j + +import io.canopy.engine.logging.core.LogProvider +import io.canopy.engine.logging.core.Logger +import org.slf4j.LoggerFactory + +/** + * SLF4J-backed implementation of [LogProvider]. + * + * This provider acts as the bridge between the engine's logging abstraction + * and the SLF4J logging ecosystem. + * + * For each requested logger name, it creates a [Slf4jLogger] that delegates + * logging operations to an SLF4J logger instance obtained via [LoggerFactory]. + * + * Note: + * SLF4J internally caches logger instances, so calling [LoggerFactory.getLogger] + * repeatedly for the same name is inexpensive. + */ +internal object Slf4jProvider : LogProvider { + + /** + * Returns a logger associated with the given [name]. + * + * The name typically represents the logger category, usually the + * fully-qualified class name requesting the logger. + */ + override fun get(name: String): Logger = Slf4jLogger(LoggerFactory.getLogger(name)) +} diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/ConsoleBanner.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/ConsoleBanner.kt index e46e38c..c46a029 100644 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/ConsoleBanner.kt +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/ConsoleBanner.kt @@ -4,6 +4,27 @@ import kotlin.math.roundToInt import java.lang.management.ManagementFactory import org.slf4j.LoggerFactory +/** + * Prints the engine startup banner to the console. + * + * The banner is loaded from a resource file (`/logo-banner.txt`) and printed + * using the logging system so that it integrates with the application's + * logging configuration. + * + * Two rendering modes are supported: + * + * - [Mode.SIMPLE] β†’ Single brand color + * - [Mode.GRADIENT] β†’ Vertical gradient across banner lines + * + * The banner output includes additional runtime metadata such as: + * - Engine version + * - JVM version + * - Operating system + * - Process ID + * + * ANSI escape sequences are used for coloring. If the terminal does not + * support ANSI colors, the banner will still render as plain text. + */ object ConsoleBanner { enum class Mode { SIMPLE, GRADIENT } @@ -11,44 +32,57 @@ object ConsoleBanner { private const val RESOURCE = "/logo-banner.txt" // Brand colors - private const val BANNER = "\u001B[38;2;56;142;60m" // canopy green (simple mode) + private const val BANNER = "\u001B[38;2;56;142;60m" private const val DIM = "\u001B[38;2;180;180;180m" - private const val LABEL = "\u001B[38;2;120;180;90m" // soft leaf green - private const val VALUE = "\u001B[38;2;255;193;7m" // golden + private const val LABEL = "\u001B[38;2;120;180;90m" + private const val VALUE = "\u001B[38;2;255;193;7m" private const val RESET = "\u001B[0m" - // Gradient for banner (top -> bottom) private val gradient: List> = listOf( - Triple(56, 142, 60), // deep canopy green - Triple(139, 195, 74), // light leaf green - Triple(255, 193, 7) // golden bird yellow + Triple(56, 142, 60), + Triple(139, 195, 74), + Triple(255, 193, 7) ) + private val ansiEnabled: Boolean = + System.console() != null && System.getenv("NO_COLOR") == null + fun print(version: String, mode: Mode = Mode.SIMPLE) { val logger = LoggerFactory.getLogger("BOOT") - val bannerText = when (mode) { - Mode.SIMPLE -> "$BANNER${readBannerText()}$RESET" - Mode.GRADIENT -> colorizeBanner(readBannerLines()) + val width = detectTerminalWidth(defaultWidth = 120) + val lines = readBannerLines() + + val bannerLines: List = when (mode) { + Mode.SIMPLE -> lines.map { line -> + "$BANNER${centerLine(line, width)}$RESET" + } + + Mode.GRADIENT -> lines.mapIndexed { index, line -> + if (!ansiEnabled) return@mapIndexed line + + val colored = colorizeLine(line, index, lines.size) + // center based on visible text (without ANSI) + val centeredPlain = centerLine(line, width) + // re-apply the computed color to the centered plain line + val ratio = index.toDouble() / (lines.size - 1).coerceAtLeast(1) + val (r, g, b) = interpolateColor(ratio) + "\u001B[38;2;$r;$g;$b" + "m$centeredPlain$RESET" + } } - val infoLine = buildInfoLine(version) + val infoLine = centerLine(buildInfoLine(version), width) logger.info( buildString { appendLine() - appendLine(bannerText) + bannerLines.forEach { appendLine(it) } appendLine(infoLine) appendLine() } ) } - private fun readBannerText(): String = ConsoleBanner::class.java.getResourceAsStream(RESOURCE) - ?.bufferedReader() - ?.readText() - ?: error("Banner not found on classpath: $RESOURCE") - private fun readBannerLines(): List = ConsoleBanner::class.java.getResourceAsStream(RESOURCE) ?.bufferedReader() ?.readLines() @@ -81,9 +115,26 @@ object ConsoleBanner { } } - private fun colorizeBanner(lines: List): String = lines.mapIndexed { index, line -> - colorizeLine(line, index, lines.size) - }.joinToString("\n") + /** + * Centers [text] within [width] columns. + * Note: This expects plain text (no ANSI codes). + */ + private fun centerLine(text: String, width: Int): String { + val visible = text.length + if (visible >= width) return text + val padLeft = (width - visible) / 2 + return " ".repeat(padLeft) + text + } + + /** + * Best-effort terminal width detection. + * - Works in many shells via $COLUMNS + * - Falls back to [defaultWidth] (useful in CI) + */ + private fun detectTerminalWidth(defaultWidth: Int): Int { + val columns = System.getenv("COLUMNS")?.toIntOrNull() + return (columns ?: defaultWidth).coerceAtLeast(40) + } private fun colorizeLine(line: String, index: Int, total: Int): String { val ratio = index.toDouble() / (total - 1).coerceAtLeast(1) diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/LogUtils.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/LogUtils.kt index f09a867..e50522f 100644 --- a/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/LogUtils.kt +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/LogUtils.kt @@ -1,7 +1,9 @@ package io.canopy.engine.logging.util -import io.canopy.engine.logging.api.LogContext -import io.canopy.engine.logging.api.Logs +import io.canopy.engine.logging.LogContext +import io.canopy.engine.logging.Logs + +/* LOG UTILITY METHODS */ fun Logs.withFrame(frame: Long, block: () -> T): T = LogContext.with("frame" to frame, block = block) diff --git a/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/Mdc.kt b/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/Mdc.kt new file mode 100644 index 0000000..0d35d16 --- /dev/null +++ b/engine/logging/src/main/kotlin/io/canopy/engine/logging/util/Mdc.kt @@ -0,0 +1,60 @@ +package io.canopy.engine.logging.util + +import org.slf4j.MDC + +/** + * Executes a block of code with temporary MDC (Mapped Diagnostic Context) values. + * + * This utility is useful when you want logs produced inside a block to include + * additional contextual fields (e.g., requestId, userId, correlationId). + * + * Behavior: + * - Saves the current MDC values for the provided keys + * - Applies the new values + * - Executes the provided block + * - Restores the previous MDC state after execution (even if an exception occurs) + * + * If a value in [fields] is `null`, the corresponding MDC key is removed. + * + * Example: + * ``` + * withTemporaryMdcContext(mapOf("requestId" to request.id)) { + * logger.info("Processing request") + * } + * ``` + * + * @param fields Key-value pairs to temporarily insert into the MDC + * @param block The code to execute with the provided MDC context + * @return The result returned by [block] + */ +internal inline fun withTemporaryMdcContext(fields: Map, block: () -> T): T { + // If there are no fields to apply, execute the block immediately + if (fields.isEmpty()) return block() + + // Store previous MDC values so they can be restored later + val previousValues = HashMap(fields.size) + + try { + fields.forEach { (key, value) -> + previousValues[key] = MDC.get(key) + + if (value == null) { + MDC.remove(key) + } else { + MDC.put(key, value.toString()) + } + } + + // Execute the wrapped block with the temporary MDC values + return block() + } finally { + // Restore the original MDC state + previousValues.forEach { (key, previousValue) -> + if (previousValue == null) { + MDC.remove(key) + } else { + MDC.put(key, previousValue) + } + } + } +} diff --git a/engine/physics/src/main/kotlin/io/canopy/engine/physics/nodes/fixture/Area2D.kt b/engine/physics/src/main/kotlin/io/canopy/engine/physics/nodes/fixture/Area2D.kt index fc761d2..6b8e716 100644 --- a/engine/physics/src/main/kotlin/io/canopy/engine/physics/nodes/fixture/Area2D.kt +++ b/engine/physics/src/main/kotlin/io/canopy/engine/physics/nodes/fixture/Area2D.kt @@ -3,7 +3,7 @@ package io.canopy.engine.physics.nodes.fixture import com.badlogic.gdx.physics.box2d.Filter import com.badlogic.gdx.physics.box2d.Fixture import io.canopy.engine.core.nodes.core.Node -import io.canopy.engine.core.signals.createSignal +import io.canopy.engine.core.reactive.event import io.canopy.engine.physics.nodes.body.PhysicsBody2D import io.canopy.engine.physics.nodes.shape.PhysicsShape2D @@ -22,10 +22,10 @@ class Area2D( private var fixture: Fixture? = null // Signals - val bodyEntered = createSignal() - val bodyExited = createSignal() - val areaEntered = createSignal() - val areaExited = createSignal() + val bodyEntered = event() + val bodyExited = event() + val areaEntered = event() + val areaExited = event() override fun nodeEnterTree() { val parentBody = (parent as? PhysicsBody2D)?.body ?: return diff --git a/engine/physics/src/main/kotlin/io/canopy/engine/physics/nodes/fixture/Collider2D.kt b/engine/physics/src/main/kotlin/io/canopy/engine/physics/nodes/fixture/Collider2D.kt index 6b37fe6..3093b2c 100644 --- a/engine/physics/src/main/kotlin/io/canopy/engine/physics/nodes/fixture/Collider2D.kt +++ b/engine/physics/src/main/kotlin/io/canopy/engine/physics/nodes/fixture/Collider2D.kt @@ -3,7 +3,7 @@ package io.canopy.engine.physics.nodes.fixture import com.badlogic.gdx.physics.box2d.Filter import com.badlogic.gdx.physics.box2d.Fixture import io.canopy.engine.core.nodes.core.Node -import io.canopy.engine.core.signals.createSignal +import io.canopy.engine.core.reactive.event import io.canopy.engine.physics.nodes.body.PhysicsBody2D import io.canopy.engine.physics.nodes.shape.PhysicsShape2D @@ -25,8 +25,8 @@ class Collider2D( private var fixture: Fixture? = null // Signals - val bodyEntered = createSignal() - val bodyExited = createSignal() + val bodyEntered = event() + val bodyExited = event() override fun nodeEnterTree() { val parentBody = (parent as? PhysicsBody2D)?.body ?: return diff --git a/engine/physics/src/test/kotlin/io/canopy/engine/physics/nodes/PhysicsBody2DTests.kt b/engine/physics/src/test/kotlin/io/canopy/engine/physics/nodes/PhysicsBody2DTests.kt index f47ec8d..db2dead 100644 --- a/engine/physics/src/test/kotlin/io/canopy/engine/physics/nodes/PhysicsBody2DTests.kt +++ b/engine/physics/src/test/kotlin/io/canopy/engine/physics/nodes/PhysicsBody2DTests.kt @@ -1,5 +1,6 @@ package io.canopy.engine.physics.nodes +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import com.badlogic.gdx.physics.box2d.Shape @@ -13,6 +14,7 @@ import io.canopy.engine.physics.systems.PhysicsSystem import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.assertNotNull +@Ignore //Physics won't be releases with version 0.1.0 class PhysicsBody2DTests { companion object { @BeforeAll @@ -32,17 +34,16 @@ class PhysicsBody2DTests { @Test fun `fixture should add shape`() { - val tree = - DynamicBody2D("root") { - Collider2D( - name = "collider", - shape = BoxShape2D() - ).at(100f, 100f) - Area2D( - name = "area", - shape = CircleShape2D() - ) - } + val tree = DynamicBody2D("root") { + Collider2D( + name = "collider", + shape = BoxShape2D() + ).at(100f, 100f) + Area2D( + name = "area", + shape = CircleShape2D() + ) + } tree.buildTree() assertEquals(2, tree.body.fixtureList.size) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d1db6b9..1cdccad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } # Gdx gdx-core = { module = "com.badlogicgames.gdx:gdx", version.ref = "gdx" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9133bcc..ac9ff0a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,15 +27,15 @@ rootProject.name = "canopy" include( ":engine:core", ":engine:input", - ":engine:graphics", - ":engine:physics", + //":engine:graphics", + //":engine:physics", ":engine:logging" ) // App modules - include( ":engine:app:app-core", - ":engine:app:app-desktop", + //":engine:app:app-desktop", ":engine:app:app-headless", ":engine:app:app-test" )