Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions docs/METADATA_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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<String, Float>` 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.
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Float> = 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<String, Float>,
): EffectInfluence = influence(distanceFromCenter, timeSinceActivation)

/**
* Compute the effect's influence at a given distance from its center,
* at a given time since activation.
Expand All @@ -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<String, Float>): 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<String, Float>,
): 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) {
Expand All @@ -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,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ data class EmitterInstance(
val effect: EmitterEffect,
val position: Vector3,
val activatedAt: Float,
val metadata: Map<String, Float> = emptyMap(),
val age: Float = 0f,
) {
val isExpired: Boolean get() = age >= effect.duration
val isExpired: Boolean get() = age >= effect.activeDuration(metadata)
}

/**
Expand Down Expand Up @@ -42,12 +43,14 @@ class EmitterManager {
effect: EmitterEffect,
position: Vector3,
currentTime: Float = 0f,
metadata: Map<String, Float> = emptyMap(),
) {
_instances.add(
EmitterInstance(
effect = effect,
position = position,
activatedAt = currentTime,
metadata = metadata,
),
)
}
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
}
}