Skip to content
18 changes: 1 addition & 17 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,7 @@ Briefly describe what this PR changes and why.
-

**Related Issues**
-

**Type of Change**
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Tests
- [ ] Chore
- [ ] Breaking change
- Closes #

**How to Test**(if applicable)
Steps to verify the change:


**Checklist**
- [ ] Code follows project style
- [ ] Self-reviewed
- [ ] Tests added/updated (if applicable)
- [ ] Docs updated (if applicable)
112 changes: 96 additions & 16 deletions engine/core/src/main/kotlin/io/canopy/engine/core/flow/Context.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
package io.canopy.engine.core.flow

import kotlin.jvm.Throws
import io.canopy.engine.core.nodes.Node

/**
* Represents a typed key used to store and resolve values from a [Context].
*
* Users are encouraged to implement this interface with their own enums instead of
* using raw strings, as it improves readability, refactor safety, and discoverability.
*
* Example:
* ```
* enum class GameContextKey(override val key: String) : ContextKey {
* THEME("theme"),
* DIFFICULTY("difficulty")
* }
* ```
*/
interface ContextKey {
val key: String
}

/**
* A transparent "scope" node used to attach contextual values to a subtree.
*
Expand All @@ -15,13 +34,20 @@ import io.canopy.engine.core.nodes.Node
*
* Example:
* ```
* enum class GameContextKey(override val key: String) : ContextKey {
* THEME("theme"),
* DIFFICULTY("difficulty")
* }
*
* root.context {
* provide("theme" to "dark", "difficulty" to 3)
* provide(GameContextKey.THEME) { "dark" }
* provide("debug") { true }
*
* +PlayerNode { ... }
* +PlayerNode { ... }
* }
*
* val theme: String = player.context("theme")
* val theme: String = player.fromContext(GameContextKey.THEME)
* val debug: Boolean = player.fromContext("debug")
* ```
*/
class Context(
Expand All @@ -30,9 +56,19 @@ class Context(
block: Context.() -> Unit = {},
) : Node<Context>(name, block) {

/**
* Provides a value under a raw string key.
*/
fun <T : Any> provide(key: String, value: () -> T?) {
provided[key] = value
}

/**
* Provides a value under a typed [ContextKey].
*/
fun <T : Any> provide(key: ContextKey, value: () -> T?) {
provide(key.key, value)
}
}

/**
Expand All @@ -44,26 +80,70 @@ class Context(
* - 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.
* Returns null if no matching key is found.
*/
@Suppress("UNCHECKED_CAST")
fun <T : Any> 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
fun <T : Any> Node<*>.fromContextOrNull(key: String): T? {
var current: Node<*>? = this

while (current != null) {
if (current is Context) {
val provider = current.provided[key]
if (provider != null) {
val value = provider.invoke() as? T
if (value != null) return value
}
}
cur = cur.parent
current = current.parent
}
error("Missing context key '$key' from node $path")

return null
}

fun <T : Any> Node<*>.lazyResolve(key: String) = lazy { resolve<T>(key) }
/**
* Typed overload of [fromContextOrNull] using a [ContextKey].
*/
fun <T : Any> Node<*>.fromContextOrNull(key: ContextKey): T? = fromContextOrNull(key.key)

/**
* Lazily resolves a context value by raw string key, returning null if not found.
*/
fun <T : Any> Node<*>.lazyFromContextOrNull(key: String): Lazy<T?> = lazy { fromContextOrNull<T>(key) }

/**
* Lazily resolves a context value by [ContextKey], returning null if not found.
*/
fun <T : Any> Node<*>.lazyFromContextOrNull(key: ContextKey): Lazy<T?> = lazy { fromContextOrNull<T>(key) }

/**
* Resolves a context value by raw string key.
*
* @throws NoSuchElementException if the key does not exist in any visible context scope.
*/
@Throws(NoSuchElementException::class)
fun <T : Any> Node<*>.fromContext(key: String): T = fromContextOrNull<T>(key)
?: throw NoSuchElementException("Context key '$key' not found")

/**
* Resolves a context value by [ContextKey].
*
* @throws NoSuchElementException if the key does not exist in any visible context scope.
*/
@Throws(NoSuchElementException::class)
fun <T : Any> Node<*>.fromContext(key: ContextKey): T = fromContext(key.key)

/**
* Fetches value from [Context]s, or null if no value is found
* Lazily resolves a context value by raw string key.
*
* @throws NoSuchElementException if the key does not exist in any visible context scope.
*/
fun <T : Any> Node<*>.resolveOrNull(key: String): T? = runCatching { resolve<T>(key) }.getOrNull()
@Throws(NoSuchElementException::class)
fun <T : Any> Node<*>.lazyFromContext(key: String): Lazy<T> = lazy { fromContext<T>(key) }

fun <T : Any> Node<*>.lazyResolveOrNull(key: String) = lazy { resolveOrNull<T>(key) }
/**
* Lazily resolves a context value by [ContextKey].
*
* @throws NoSuchElementException if the key does not exist in any visible context scope.
*/
@Throws(NoSuchElementException::class)
fun <T : Any> Node<*>.lazyFromContext(key: ContextKey): Lazy<T> = lazy { fromContext<T>(key) }
22 changes: 1 addition & 21 deletions engine/core/src/main/kotlin/io/canopy/engine/core/nodes/Node.kt
Original file line number Diff line number Diff line change
Expand Up @@ -352,26 +352,6 @@ abstract class Node<N : Node<N>> protected constructor(

fun hasChildType(type: KClass<out Node<*>>) = 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
* ============================================================ */
Expand Down Expand Up @@ -551,7 +531,7 @@ abstract class Node<N : Node<N>> protected constructor(

infix fun child(node: Node<*>) = addChild(node)

fun groups(vararg groups: String) = apply { groups.forEach { addGroup(it) } }
// fun groups(vararg groups: String) = apply { groups.forEach { addGroup(it) } }

fun <T : Node<T>> patch(path: String, handler: T.() -> Unit) = getNode<T>(path).apply(handler)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,15 @@ abstract class Node2D<N : Node2D<N>> protected constructor(name: String, block:
* ============================================================ */

/** Local position in 2D space. */
open var position: Vector2 = Vector2.Zero
var position: Vector2 = Vector2.Zero

/** Local scale in 2D space. */
open var scale: Vector2 = Vector2(1f, 1f)
var scale: Vector2 = Vector2(1f, 1f)

/** Local rotation in radians. */
open var rotation: Float = 0f
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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,6 @@ class ContextTests {

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

Expand All @@ -57,7 +51,7 @@ class ContextTests {
// Find the "child" node. Adjust if you have find-by-path utilities.
val child = root.getNode<EmptyNode>("./child")

val debug: Boolean = child.resolve("debug")
val debug: Boolean = child.fromContext("debug")
assertTrue(debug)
}

Expand All @@ -76,7 +70,7 @@ class ContextTests {

val b = root.getNode<EmptyNode>("./a/b")

val debug: Boolean = b.resolve("debug")
val debug: Boolean = b.fromContext("debug")
assertTrue(debug)
}

Expand All @@ -100,8 +94,8 @@ class ContextTests {
val a = root.getNode<EmptyNode>("./a")
val b = root.getNode<EmptyNode>("./b")

val aDebug: Boolean = a.resolve("debug")
val bDebug: Boolean = b.resolve("debug")
val aDebug: Boolean = a.fromContext("debug")
val bDebug: Boolean = b.fromContext("debug")

assertTrue(aDebug)
assertFalse(bDebug)
Expand All @@ -115,7 +109,7 @@ class ContextTests {

val child = root.getNode<EmptyNode>("./child")

val missing: String? = child.resolveOrNull("nope")
val missing: String? = child.fromContextOrNull("nope")
assertNull(missing)
}

Expand All @@ -127,8 +121,8 @@ class ContextTests {

val child = root.getNode<EmptyNode>("./child")

val ex = assertThrows<IllegalStateException> {
child.resolve<Int>("missing")
val ex = assertThrows<NoSuchElementException> {
child.fromContext<Int>("missing")
}

// Optional: if your error message includes path/name
Expand All @@ -149,8 +143,8 @@ class ContextTests {

val child = root.getNode<EmptyNode>("./child")

val debug: Boolean = child.resolve("debugMode")
val season: String = child.resolve("season")
val debug: Boolean = child.fromContext("debugMode")
val season: String = child.fromContext("season")

assertTrue(debug)
assertEquals("winter", season)
Expand All @@ -170,8 +164,8 @@ class ContextTests {

val child = root.getNode<EmptyNode>("./child")

assertEquals(1, child.resolve("keyA"))
assertEquals(2, child.resolve("keyB"))
assertEquals(1, child.fromContext("keyA"))
assertEquals(2, child.fromContext("keyB"))
}

@Test
Expand All @@ -191,7 +185,7 @@ class ContextTests {
root.buildTree()

val c = root.getNode<EmptyNode>("./a/b/c")
assertEquals(42, c.resolve("x"))
assertEquals(42, c.fromContext("x"))
}

@Test
Expand All @@ -211,7 +205,7 @@ class ContextTests {

val c = root.getNode<EmptyNode>("./a")

assertEquals(1, c.resolve("keyA"))
assertEquals(1, c.fromContext("keyA"))
}

// --- Tiny adapter -------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ class NodeTests {
val node = CustomScene {
patch<EmptyNode2D>("./empty") {
name = "patched"
at(100f, 100f)
position = Vector2(100f, 100f)
}
}
node.buildTree()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import kotlinx.serialization.serializer
* Note:
* The [config] lambda is applied last, so callers can override any default.
*/
object JsonParser {
object Json {

/* ============================================================
* Decoding
Expand Down Expand Up @@ -111,13 +111,13 @@ object JsonParser {
* Useful when you already parsed JSON and want to decode only a subtree.
*/
inline fun <reified T> decodeJsonElement(serializer: KSerializer<T> = serializer(), element: JsonElement): T =
Json.decodeFromJsonElement(serializer, element)
kotlinx.serialization.json.Json.decodeFromJsonElement(serializer, element)

/**
* Encodes [data] into a [JsonElement] using the provided [serializer].
*/
inline fun <reified T> encodeJsonElement(serializer: KSerializer<T> = serializer(), data: T): JsonElement =
Json.encodeToJsonElement(serializer, data)
kotlinx.serialization.json.Json.encodeToJsonElement(serializer, data)

fun buildJson(module: SerializersModule? = null, config: JsonBuilder.() -> Unit = {}) = Json {
if (module != null) serializersModule = module
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import kotlinx.serialization.modules.SerializersModule
* tomlkt parses TOML according to the TOML specification. This parser does not attempt
* to accept non-standard TOML extensions by default.
*/
object TomlParser {
object Toml {

/* ============================================================
* Decoding
Expand Down
Loading
Loading