From 351bffea6a20f48f5d40b31c421696ff6137fd17 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 1 Mar 2026 12:21:38 -0500 Subject: [PATCH 1/2] Add emitter metadata channels --- README.md | 19 +++++++ docs/METADATA_GUIDE.md | 50 +++++++++++++++++ .../socket/phosphor/emitter/EmitterEffect.kt | 53 ++++++++++++++++--- .../socket/phosphor/emitter/EmitterManager.kt | 7 ++- .../socket/phosphor/emitter/MetadataKeys.kt | 24 +++++++++ .../phosphor/emitter/EmitterEffectTest.kt | 44 +++++++++++++++ .../phosphor/emitter/EmitterManagerTest.kt | 43 +++++++++++++++ 7 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 docs/METADATA_GUIDE.md create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/MetadataKeys.kt diff --git a/README.md b/README.md index 1bed72c..7b89b21 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,25 @@ Phase transitions trigger effects automatically — spark bursts, substrate ripp --- +## Metadata-Driven Emitters + +Emitter instances can carry generic scalar metadata that effects consume at render time. + +```kotlin +emitters.emit( + effect = EmitterEffect.SparkBurst(), + position = Vector3.ZERO, + metadata = mapOf( + MetadataKeys.INTENSITY to 1.4f, + MetadataKeys.HEAT to 0.85f, + ), +) +``` + +`SparkBurst` uses these values to scale brightness and expansion while preserving the same base effect shape. See [docs/METADATA_GUIDE.md](docs/METADATA_GUIDE.md) for the built-in keys and integration pattern. + +--- + ## Platforms Phosphor compiles to: diff --git a/docs/METADATA_GUIDE.md b/docs/METADATA_GUIDE.md new file mode 100644 index 0000000..eb179bb --- /dev/null +++ b/docs/METADATA_GUIDE.md @@ -0,0 +1,50 @@ +# Metadata-Driven Emitters + +Phosphor emitters can now carry per-instance scalar metadata. This lets an excitation source keep the effect shape stable while varying the metabolic weight of each trigger. + +## Overview + +`EmitterManager.emit(...)` accepts a `metadata: Map` argument. The map is stored on the live `EmitterInstance` and passed into `EmitterEffect.influence(...)` on every query. + +When metadata is absent, effects keep their legacy behavior. + +## Built-in Keys + +- `MetadataKeys.INTENSITY`: scales an effect's peak output. +- `MetadataKeys.HEAT`: biases effects toward hotter, more energetic behavior. +- `MetadataKeys.DENSITY`: reserved for effects that vary particle or fragment density. +- `MetadataKeys.DURATION_SCALE`: stretches or compresses effect lifetime. +- `MetadataKeys.RADIUS_SCALE`: widens or narrows spatial reach. + +## Example + +```kotlin +val emitters = EmitterManager() + +emitters.emit( + effect = EmitterEffect.SparkBurst(), + position = Vector3.ZERO, + metadata = mapOf( + MetadataKeys.INTENSITY to 1.6f, + MetadataKeys.HEAT to 0.9f, + MetadataKeys.RADIUS_SCALE to 1.25f, + ), +) +``` + +## Built-in Behavior + +`EmitterEffect.SparkBurst` consumes metadata today: + +- `INTENSITY` scales ring brightness. +- `HEAT` increases outward expansion speed. +- `DURATION_SCALE` stretches the burst lifetime. +- `RADIUS_SCALE` widens the area of influence. + +Other built-in effects ignore metadata for now and remain behaviorally identical to earlier releases. + +## Guidance For Consumers + +- Keep keys generic. Map domain values onto visual semantics before they enter Phosphor. +- Prefer normalized floats where practical so effect tuning stays stable across inputs. +- Reuse `emptyMap()` when you have no metadata to avoid unnecessary allocations. diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/EmitterEffect.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/EmitterEffect.kt index faa3246..c533dbc 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/EmitterEffect.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/EmitterEffect.kt @@ -23,6 +23,26 @@ sealed class EmitterEffect( val radius: Float, val peakIntensity: Float = 1f, ) { + /** + * Resolve how long an effect instance should stay alive for a metadata payload. + * + * Most effects use their base duration unchanged; metadata-aware effects can + * override this when lifespan itself is part of the modulation. + */ + open fun activeDuration(metadata: Map = emptyMap()): Float = duration + + /** + * Compute the effect's influence with optional per-instance metadata. + * + * The default implementation preserves the original behavior by ignoring + * metadata and delegating to the legacy two-parameter overload. + */ + open fun influence( + distanceFromCenter: Float, + timeSinceActivation: Float, + metadata: Map, + ): EffectInfluence = influence(distanceFromCenter, timeSinceActivation) + /** * Compute the effect's influence at a given distance from its center, * at a given time since activation. @@ -41,15 +61,34 @@ sealed class EmitterEffect( val ringWidth: Float = 0.5f, val expansionSpeed: Float = 8f, val palette: AsciiLuminancePalette = AsciiLuminancePalette.EXECUTE, - ) : EmitterEffect("spark_burst", duration, radius) { + peakIntensity: Float = 1f, + ) : EmitterEffect("spark_burst", duration, radius, peakIntensity) { + override fun activeDuration(metadata: Map): Float { + return duration * (metadata[MetadataKeys.DURATION_SCALE] ?: 1f) + } + + override fun influence( + distanceFromCenter: Float, + timeSinceActivation: Float, + ): EffectInfluence = influence(distanceFromCenter, timeSinceActivation, emptyMap()) + override fun influence( distanceFromCenter: Float, timeSinceActivation: Float, + metadata: Map, ): EffectInfluence { - if (timeSinceActivation >= duration || timeSinceActivation < 0f) return EffectInfluence.NONE - if (distanceFromCenter > radius) return EffectInfluence.NONE + val effectiveDuration = activeDuration(metadata) + if (timeSinceActivation >= effectiveDuration || timeSinceActivation < 0f) return EffectInfluence.NONE + + val radiusScale = metadata[MetadataKeys.RADIUS_SCALE] ?: 1f + val effectiveRadius = radius * radiusScale + if (distanceFromCenter > effectiveRadius) return EffectInfluence.NONE - val ringCenter = expansionSpeed * timeSinceActivation + val heat = metadata[MetadataKeys.HEAT] ?: 0.5f + val intensityScale = metadata[MetadataKeys.INTENSITY] ?: peakIntensity + val effectiveExpansionSpeed = expansionSpeed * (0.5f + heat.coerceAtLeast(0f)) + + val ringCenter = effectiveExpansionSpeed * timeSinceActivation val distFromRing = abs(distanceFromCenter - ringCenter) val ringInfluence = if (distFromRing < ringWidth) { @@ -59,12 +98,12 @@ sealed class EmitterEffect( } // Decay over time - val timeDecay = 1f - (timeSinceActivation / duration) - val intensity = ringInfluence * timeDecay * peakIntensity + val timeDecay = 1f - (timeSinceActivation / effectiveDuration) + val intensity = ringInfluence * timeDecay * intensityScale return EffectInfluence( luminanceModifier = intensity * 0.6f, - paletteOverride = if (intensity > 0.3f) palette else null, + paletteOverride = if (intensity > 0.3f || heat >= 0.75f) palette else null, intensity = intensity, ) } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/EmitterManager.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/EmitterManager.kt index 9371d05..d6aee73 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/EmitterManager.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/EmitterManager.kt @@ -9,9 +9,10 @@ data class EmitterInstance( val effect: EmitterEffect, val position: Vector3, val activatedAt: Float, + val metadata: Map = emptyMap(), val age: Float = 0f, ) { - val isExpired: Boolean get() = age >= effect.duration + val isExpired: Boolean get() = age >= effect.activeDuration(metadata) } /** @@ -42,12 +43,14 @@ class EmitterManager { effect: EmitterEffect, position: Vector3, currentTime: Float = 0f, + metadata: Map = emptyMap(), ) { _instances.add( EmitterInstance( effect = effect, position = position, activatedAt = currentTime, + metadata = metadata, ), ) } @@ -91,7 +94,7 @@ class EmitterManager { val dx = worldX - instance.position.x val dz = worldZ - instance.position.z val distance = kotlin.math.sqrt(dx * dx + dz * dz) - val influence = instance.effect.influence(distance, instance.age) + val influence = instance.effect.influence(distance, instance.age, instance.metadata) if (influence.intensity > 0f) { result = result + influence } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/MetadataKeys.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/MetadataKeys.kt new file mode 100644 index 0000000..ec58d0c --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/emitter/MetadataKeys.kt @@ -0,0 +1,24 @@ +package link.socket.phosphor.emitter + +/** + * Well-known metadata keys for generic emitter modulation. + * + * These remain domain-neutral so bridges can map their own numeric signals + * onto common visual semantics without leaking source concepts into Phosphor. + */ +object MetadataKeys { + /** Overall intensity multiplier. Scales an effect's peak intensity. */ + const val INTENSITY = "phosphor.intensity" + + /** Thermal energy. Higher values can bias effects toward hotter, brighter behavior. */ + const val HEAT = "phosphor.heat" + + /** Density multiplier for effects that vary their particle or fragment count. */ + const val DENSITY = "phosphor.density" + + /** Duration multiplier for effects that stretch or compress their lifespan. */ + const val DURATION_SCALE = "phosphor.duration_scale" + + /** Radius multiplier for effects that widen or tighten spatial reach. */ + const val RADIUS_SCALE = "phosphor.radius_scale" +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/emitter/EmitterEffectTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/emitter/EmitterEffectTest.kt index ed2036d..707913f 100644 --- a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/emitter/EmitterEffectTest.kt +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/emitter/EmitterEffectTest.kt @@ -69,6 +69,50 @@ class EmitterEffectTest { } } + @Test + fun `SparkBurst metadata intensity can suppress influence`() { + val burst = EmitterEffect.SparkBurst() + val ringPos = burst.expansionSpeed * 0.05f + val influence = + burst.influence( + distanceFromCenter = ringPos, + timeSinceActivation = 0.05f, + metadata = mapOf(MetadataKeys.INTENSITY to 0f), + ) + assertEquals(0f, influence.intensity) + } + + @Test + fun `SparkBurst higher heat pushes the ring outward faster`() { + val burst = EmitterEffect.SparkBurst() + val lowHeat = + burst.influence( + distanceFromCenter = 1.1f, + timeSinceActivation = 0.1f, + metadata = mapOf(MetadataKeys.HEAT to 0.1f), + ) + val highHeat = + burst.influence( + distanceFromCenter = 1.1f, + timeSinceActivation = 0.1f, + metadata = mapOf(MetadataKeys.HEAT to 0.9f), + ) + assertTrue(highHeat.intensity > lowHeat.intensity) + } + + @Test + fun `SparkBurst empty metadata preserves legacy behavior`() { + val burst = EmitterEffect.SparkBurst() + val legacy = burst.influence(distanceFromCenter = 0.4f, timeSinceActivation = 0.05f) + val metadataAware = + burst.influence( + distanceFromCenter = 0.4f, + timeSinceActivation = 0.05f, + metadata = emptyMap(), + ) + assertEquals(legacy, metadataAware) + } + // --- HeightPulse --- @Test diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/emitter/EmitterManagerTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/emitter/EmitterManagerTest.kt index 2d1b296..b88b0ec 100644 --- a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/emitter/EmitterManagerTest.kt +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/emitter/EmitterManagerTest.kt @@ -135,6 +135,14 @@ class EmitterManagerTest { assertEquals(5.5f, manager.instances.first().activatedAt) } + @Test + fun `emit records metadata on instance`() { + val manager = EmitterManager() + val metadata = mapOf(MetadataKeys.INTENSITY to 0.25f, MetadataKeys.HEAT to 0.9f) + manager.emit(EmitterEffect.SparkBurst(), Vector3.ZERO, metadata = metadata) + assertEquals(metadata, manager.instances.first().metadata) + } + @Test fun `instance isExpired when age reaches duration`() { val instance = @@ -168,4 +176,39 @@ class EmitterManagerTest { manager.update(0.3f) assertEquals(0.6f, manager.instances.first().age, 0.001f) } + + @Test + fun `aggregateInfluenceAt passes metadata into effect computation`() { + val withoutMetadata = EmitterManager() + withoutMetadata.emit(EmitterEffect.SparkBurst(), Vector3.ZERO) + withoutMetadata.update(0.05f) + + val withZeroIntensity = EmitterManager() + withZeroIntensity.emit( + EmitterEffect.SparkBurst(), + Vector3.ZERO, + metadata = mapOf(MetadataKeys.INTENSITY to 0f), + ) + withZeroIntensity.update(0.05f) + + val baseline = withoutMetadata.aggregateInfluenceAt(0.4f, 0f) + val suppressed = withZeroIntensity.aggregateInfluenceAt(0.4f, 0f) + + assertTrue(baseline.intensity > 0f) + assertEquals(EffectInfluence.NONE, suppressed) + } + + @Test + fun `duration scale metadata extends instance lifetime`() { + val manager = EmitterManager() + manager.emit( + EmitterEffect.SparkBurst(duration = 0.5f), + Vector3.ZERO, + metadata = mapOf(MetadataKeys.DURATION_SCALE to 2f), + ) + + manager.update(0.75f) + + assertEquals(1, manager.activeCount) + } } From 176c1729ee17016ff503c47ff44dc5a816ecfca9 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 1 Mar 2026 12:23:37 -0500 Subject: [PATCH 2/2] Bump version to 0.3.0 --- README.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b89b21..89e8819 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Add the dependency: ```kotlin // build.gradle.kts dependencies { - implementation("link.socket:phosphor-core:0.2.2") + implementation("link.socket:phosphor-core:0.3.0") } ``` diff --git a/gradle.properties b/gradle.properties index 5c7021c..8f3f425 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,4 +15,4 @@ kotlin.version=2.3.10 org.jetbrains.dokka.experimental.gradle.pluginMode=V2EnabledWithHelpers #Phosphor -phosphorVersion=0.2.3 +phosphorVersion=0.3.0