This commit is contained in:
Moritz Ruth 2023-05-23 00:25:19 +02:00
parent c4b134bb80
commit ea15c19e70
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
26 changed files with 653 additions and 215 deletions

View file

@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm")
kotlin("plugin.serialization")
application
}
@ -23,6 +24,11 @@ dependencies {
implementation(KotlinX.collections.immutable)
implementation(KotlinX.serialization.json)
implementation(KotlinX.datetime)
implementation(Ktor.server.core)
implementation(Ktor.server.websockets)
implementation(Ktor.server.cio)
implementation(Ktor.server.contentNegotiation)
implementation(Ktor.plugins.serialization.kotlinx.json)
implementation("org.slf4j:slf4j-simple:_")
implementation("com.fazecast:jSerialComm:_")
implementation("io.github.oshai:kotlin-logging-jvm:_")

View file

@ -1,50 +1,23 @@
package de.moritzruth.lampenfieber
import de.moritzruth.theaterdsl.StepTrigger
import de.moritzruth.theaterdsl.createAct
import de.moritzruth.lampenfieber.device.StairvilleTriLedBar
import de.moritzruth.theaterdsl.dmx.DmxAddress
import de.moritzruth.theaterdsl.dmx.EnttecOpenDmxUsb
import de.moritzruth.theaterdsl.runShow
import de.moritzruth.theaterdsl.value.Color
import de.moritzruth.theaterdsl.value.degrees
import de.moritzruth.theaterdsl.value.percent
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import de.moritzruth.theaterdsl.show.createShow
import de.moritzruth.theaterdsl.show.runShow
import kotlinx.collections.immutable.persistentSetOf
val bar = FloorBarDevice(DmxAddress(15u))
val bar = StairvilleTriLedBar(DmxAddress(15u))
val firstAct = createAct("Erster Akt") {
val show = createShow {
act("Erster Akt") {
scene("Prolog") {
step {
trigger = StepTrigger.MusicStart("1. Ouvertüre: Dissonanzen in Schwarz und Weiß", 2.minutes)
actors {
+ "Sophie / von links"
}
bar.color.static(Color(100.degrees, 100.percent, 100.percent))
bar.brightness.switch(100.percent, 0.percent, 10.seconds)
// bar.color.fade(Color(100.degrees, 100.percent, 100.percent), 2.seconds)
}
step {
trigger = StepTrigger.MusicEnd
actors {
- "Sophie"
}
}
}
}
suspend fun main() {
EnttecOpenDmxUsb.start()
runShow(
devices = setOf(
bar
),
acts = listOf(
firstAct
)
)
runShow(show, persistentSetOf(bar))
}

View file

@ -0,0 +1,20 @@
package de.moritzruth.lampenfieber.device
import de.moritzruth.theaterdsl.device.Device
import de.moritzruth.theaterdsl.device.PercentageDV
import de.moritzruth.theaterdsl.dmx.DmxAddress
import de.moritzruth.theaterdsl.dmx.DmxDataWriter
import kotlinx.collections.immutable.persistentSetOf
class FuturelightDmh160(override val firstChannel: DmxAddress) : Device {
override val numberOfChannels: UInt = 16u
override fun writeDmxData(writer: DmxDataWriter) {
writer.writePercentage(brightness.getCurrentValue())
}
val pan = AngleDV()
val brightness = PercentageDV()
override val dvs = persistentSetOf(brightness)
}

View file

@ -0,0 +1,20 @@
package de.moritzruth.lampenfieber.device
import de.moritzruth.theaterdsl.device.Device
import de.moritzruth.theaterdsl.device.PercentageDV
import de.moritzruth.theaterdsl.dmx.DmxAddress
import de.moritzruth.theaterdsl.dmx.DmxDataWriter
import kotlinx.collections.immutable.persistentSetOf
class SimpleDimmer(channel: DmxAddress) : Device {
override val firstChannel = channel
override val numberOfChannels: UInt = 1u
override fun writeDmxData(writer: DmxDataWriter) {
writer.writePercentage(brightness.getCurrentValue())
}
val brightness = PercentageDV()
override val dvs = persistentSetOf(brightness)
}

View file

@ -1,10 +1,13 @@
package de.moritzruth.lampenfieber
package de.moritzruth.lampenfieber.device
import de.moritzruth.theaterdsl.*
import de.moritzruth.theaterdsl.device.ColorDV
import de.moritzruth.theaterdsl.device.Device
import de.moritzruth.theaterdsl.device.PercentageDV
import de.moritzruth.theaterdsl.dmx.DmxAddress
import de.moritzruth.theaterdsl.dmx.DmxDataWriter
import kotlinx.collections.immutable.persistentSetOf
class FloorBarDevice(override val firstChannel: DmxAddress): Device {
class StairvilleTriLedBar(override val firstChannel: DmxAddress) : Device {
override val numberOfChannels: UInt = 5u
override fun writeDmxData(writer: DmxDataWriter) {
@ -19,4 +22,10 @@ class FloorBarDevice(override val firstChannel: DmxAddress): Device {
val color = ColorDV()
val brightness = PercentageDV()
val strobeSpeed = PercentageDV()
override val dvs = persistentSetOf(
color,
brightness,
strobeSpeed
)
}

View file

@ -1,5 +0,0 @@
package de.moritzruth.theaterdsl
import kotlinx.collections.immutable.ImmutableList
class Act(val name: String, val scenes: ImmutableList<Scene>)

View file

@ -1,59 +0,0 @@
package de.moritzruth.theaterdsl
import de.moritzruth.theaterdsl.dmx.EnttecOpenDmxUsb
import de.moritzruth.theaterdsl.dmx.PerDeviceDmxDataWriter
import kotlinx.coroutines.*
import kotlin.math.roundToLong
import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis
import kotlin.time.Duration
suspend fun runShow(devices: Set<Device>, acts: List<Act>) = coroutineScope {
launch {
while(isActive) {
val took = measureTimeMillis {
val data = UByteArray(512)
val writer = PerDeviceDmxDataWriter(data)
for (device in devices) {
writer.reset(0, device.firstChannel, device.numberOfChannels)
device.writeDmxData(writer)
}
EnttecOpenDmxUsb.data = data
}
delay(((1000.0 / 30) - took).roundToLong())
}
}
var lastStepJob: Job? = null
for (act in acts) {
println("ACT: ${act.name}\n")
for (scene in act.scenes) {
println("SCENE: ${scene.name}")
for (step in scene.steps) {
println("NEXT STEP: ${step.trigger.format()}")
val input = readln()
if (input === "q") exitProcess(0)
lastStepJob?.cancelAndJoin()
lastStepJob = launch(SupervisorJob()) {
val context = object : StepRunContext, CoroutineScope by this {
}
with(step) {
runner?.let { context.it() }
}
}
}
}
}
println("\nEND OF THE SHOW")
delay(Duration.INFINITE)
}

View file

@ -1,5 +0,0 @@
package de.moritzruth.theaterdsl
import kotlinx.collections.immutable.ImmutableList
class Scene(val name: String, val steps: ImmutableList<Step>)

View file

@ -1,42 +0,0 @@
package de.moritzruth.theaterdsl
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.CoroutineScope
enum class PropPosition {
PROSCENIUM_LEFT,
PROSCENIUM_CENTER,
PROSCENIUM_RIGHT,
LEFT,
CENTER,
RIGHT,
BACKDROP
}
@TheaterDslMarker
interface StepRunContext: CoroutineScope
typealias StepRunner = (StepRunContext.() -> Unit)
/**
* A string which has optional additional details separated by " / "
*/
@JvmInline
value class StringWithDetails(val value: String) {
companion object {
const val DELIMITER = " / "
}
val main get() = value.split(DELIMITER)[0]
val details: String? get() = value.split(DELIMITER, limit = 2).takeIf { it.size == 2 }?.let { it[1] }
val hasDetails get() = details !== null
}
class Step(
val trigger: StepTrigger,
val actorEntrances: ImmutableSet<StringWithDetails>,
val actorExits: ImmutableSet<StringWithDetails>,
val actorsOnStage: ImmutableList<String>,
val runner: StepRunner?
)

View file

@ -1,37 +0,0 @@
package de.moritzruth.theaterdsl
import kotlin.time.Duration
sealed interface StepTrigger {
fun format(): String
data class MusicStart(val title: String, val duration: Duration): StepTrigger {
override fun format() = "music start: $title"
}
object MusicEnd: StepTrigger {
override fun format() = "music end"
}
data class Curtain(val state: State, val whileMoving: Boolean): StepTrigger {
enum class State {
OPEN,
CLOSED
}
override fun format() = "curtain: $state${if (whileMoving) "(while moving)" else ""}"
}
data class Light(val state: State, val whileFading: Boolean): StepTrigger {
enum class State {
ON,
OFF
}
override fun format() = "light: $state${if (whileFading) "(while fading)" else ""}"
}
data class Custom(val text: String): StepTrigger {
override fun format() = "custom: $text"
}
}

View file

@ -1,9 +1,12 @@
package de.moritzruth.theaterdsl
package de.moritzruth.theaterdsl.device
import de.moritzruth.theaterdsl.dmx.DmxAddress
import de.moritzruth.theaterdsl.dmx.DmxDataWriter
import kotlinx.collections.immutable.ImmutableSet
interface Device {
val dvs: ImmutableSet<DynamicValue<*>>
val firstChannel: DmxAddress
val numberOfChannels: UInt
fun writeDmxData(writer: DmxDataWriter)

View file

@ -1,4 +1,4 @@
package de.moritzruth.theaterdsl
package de.moritzruth.theaterdsl.device
import de.moritzruth.theaterdsl.value.*
import kotlinx.collections.immutable.ImmutableList
@ -13,11 +13,12 @@ import kotlin.time.ExperimentalTime
import kotlin.time.TimeSource
interface DynamicValue<T> {
fun reset()
fun getCurrentValue(): T
}
@OptIn(ExperimentalTime::class)
class PercentageDV(initialStaticValue: Percentage = 0.percent): DynamicValue<Percentage> {
class PercentageDV(private val initialStaticValue: Percentage = 0.percent) : DynamicValue<Percentage> {
private sealed interface State {
data class Static(val value: Percentage) : State
@ -48,6 +49,10 @@ class PercentageDV(initialStaticValue: Percentage = 0.percent): DynamicValue<Per
stateChangeMark = TimeSource.Monotonic.markNow()
}
override fun reset() {
state = State.Static(initialStaticValue)
}
override fun getCurrentValue(): Percentage {
val elapsedTime = stateChangeMark.elapsedNow()
@ -97,7 +102,7 @@ class PercentageDV(initialStaticValue: Percentage = 0.percent): DynamicValue<Per
}
@OptIn(ExperimentalTime::class)
class ColorDV(initialStaticValue: Color = Color.WHITE): DynamicValue<Color> {
class ColorDV(private val initialStaticValue: Color = Color.WHITE) : DynamicValue<Color> {
private sealed interface State {
data class Static(val value: Color) : State
data class Fade(val start: Color, val end: Color, val duration: Duration) : State {
@ -114,6 +119,10 @@ class ColorDV(initialStaticValue: Color = Color.WHITE): DynamicValue<Color> {
stateChangeMark = TimeSource.Monotonic.markNow()
}
override fun reset() {
state = State.Static(initialStaticValue)
}
override fun getCurrentValue(): Color {
val elapsedTime = stateChangeMark.elapsedNow()

View file

@ -2,11 +2,14 @@ package de.moritzruth.theaterdsl.dmx
import com.fazecast.jSerialComm.SerialPort
import com.fazecast.jSerialComm.SerialPortIOException
import io.github.oshai.KotlinLogging
import kotlin.concurrent.thread
object EnttecOpenDmxUsb {
private const val PORT_NAME = "FT232R USB UART"
private val logger = KotlinLogging.logger("EnttecOpenDmxUsb")
var data = UByteArray(512)
set(value) {
require(value.size <= 512) { "array length <= 512" }
@ -78,11 +81,11 @@ object EnttecOpenDmxUsb {
port.numStopBits = SerialPort.TWO_STOP_BITS
port.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED)
println("DMX interface connected")
logger.info("DMX interface connected")
return true
} else {
if (port.lastErrorCode == 13) System.err.println("The serial port could not be opened because of insufficient permissions (13).")
else System.err.println("The serial port could not be opened (${port.lastErrorCode}).")
if (port.lastErrorCode == 13) logger.error("The serial port could not be opened because of insufficient permissions (13).")
else logger.error("The serial port could not be opened (${port.lastErrorCode}).")
return false
}
}

View file

@ -0,0 +1,10 @@
package de.moritzruth.theaterdsl.show
import kotlinx.serialization.Serializable
@Serializable
data class Act(val name: String, val scenes: List<Scene>)
operator fun List<Act>.get(position: ShowPosition) =
if (position == ShowPosition.START) Step.START
else this[position.act].scenes[position.scene].steps[position.step]

View file

@ -1,12 +1,20 @@
package de.moritzruth.theaterdsl
package de.moritzruth.theaterdsl.show
import de.moritzruth.theaterdsl.runOptionalSanityChecks
import de.moritzruth.theaterdsl.util.StringWithDetails
import io.github.oshai.KotlinLogging
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
@DslMarker
annotation class TheaterDslMarker
@TheaterDslMarker
interface ShowBuilderContext {
fun act(name: String, build: ActBuilderContext.() -> Unit)
}
@TheaterDslMarker
interface ActBuilderContext {
fun scene(name: String, build: SceneBuilderContext.() -> Unit)
@ -19,16 +27,19 @@ interface SceneBuilderContext {
@TheaterDslMarker
interface StepDataBuilderContext {
var trigger: StepTrigger
var trigger: StepCue
val props: PropsBuilderMap
fun actors(build: ActorsBuildContext.() -> Unit)
fun onRun(block: StepRunner)
}
class PropsBuilderMap(private val backingMap: MutableMap<PropPosition, StringWithDetails>) {
operator fun set(position: PropPosition, prop: StringWithDetails) = backingMap.set(position, prop)
operator fun set(position: PropPosition, prop: String) = backingMap.set(position, StringWithDetails(prop))
class PropsBuilderMap(private val backingMap: MutableMap<PropPosition, StringWithDetails?>) {
operator fun set(position: PropPosition, prop: String?) = backingMap.set(position, prop?.let { StringWithDetails(it) })
operator fun invoke(block: (PropsBuilderMap) -> Unit) {
block(this)
}
}
@TheaterDslMarker
@ -54,9 +65,23 @@ class ActorsBuildContext(
}
@TheaterDslMarker
fun createAct(name: String, build: ActBuilderContext.() -> Unit): Act {
fun createShow(build: ShowBuilderContext.() -> Unit): Show {
val acts = mutableListOf<Act>()
object : ShowBuilderContext {
override fun act(name: String, build: ActBuilderContext.() -> Unit) {
acts.add(buildAct(acts.size, name, build))
}
}.build()
return Show(acts.toList())
}
@TheaterDslMarker
private fun buildAct(actIndex: Int, name: String, build: ActBuilderContext.() -> Unit): Act {
val scenes = mutableListOf<Scene>()
val actorsOnStage = mutableListOf<String>()
val props = PropPosition.values().associateWith<PropPosition, StringWithDetails?> { null }.toMutableMap()
object : ActBuilderContext {
override fun scene(name: String, build: SceneBuilderContext.() -> Unit) {
@ -64,20 +89,20 @@ fun createAct(name: String, build: ActBuilderContext.() -> Unit): Act {
object : SceneBuilderContext {
override fun step(build: StepDataBuilderContext.() -> Unit) {
var nullableTrigger: StepTrigger? = null
val propsMap = mutableMapOf<PropPosition, StringWithDetails>()
var nullableTrigger: StepCue? = null
val changedProps = mutableMapOf<PropPosition, StringWithDetails?>()
val actorEntrances = mutableSetOf<StringWithDetails>()
val actorExits = mutableSetOf<StringWithDetails>()
var runner: StepRunner? = null
object : StepDataBuilderContext {
override var trigger: StepTrigger
override var trigger: StepCue
get() = nullableTrigger ?: throw IllegalStateException("trigger was not set yet")
set(value) {
nullableTrigger = value
}
override val props = PropsBuilderMap(propsMap)
override val props = PropsBuilderMap(changedProps)
override fun actors(build: ActorsBuildContext.() -> Unit) {
ActorsBuildContext(actorEntrances, actorExits).build()
@ -122,12 +147,17 @@ fun createAct(name: String, build: ActBuilderContext.() -> Unit): Act {
actorsOnStage.removeAll(actorExitsNames)
actorsOnStage.addAll(actorEntrancesNames)
changedProps.forEach { (k, v) -> props[k] = v }
steps.add(
Step(
ShowPosition(actIndex, scenes.size, steps.size),
trigger,
actorEntrances.toImmutableSet(),
actorExits.toImmutableSet(),
actorsOnStage.toImmutableList(),
props.toImmutableMap(),
changedProps.isNotEmpty(),
runner
)
)

View file

@ -0,0 +1,230 @@
package de.moritzruth.theaterdsl.show
import de.moritzruth.theaterdsl.device.Device
import de.moritzruth.theaterdsl.device.DynamicValue
import de.moritzruth.theaterdsl.dmx.EnttecOpenDmxUsb
import de.moritzruth.theaterdsl.dmx.PerDeviceDmxDataWriter
import de.moritzruth.theaterdsl.util.InstantAsEpochMillisecondsSerializer
import de.moritzruth.theaterdsl.util.mapState
import io.github.oshai.KLogger
import io.github.oshai.KotlinLogging
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.roundToLong
import kotlin.system.measureTimeMillis
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
class ShowContext(
val devices: ImmutableSet<Device>,
val show: Show,
val logger: KLogger,
val stateFlow: MutableStateFlow<ShowState>,
val outputDataFreeze: AtomicInteger
) {
val stepFlow = stateFlow.mapState {
if (it.position == ShowPosition.START) Step.START
else show.acts[it.position.act].scenes[it.position.scene].steps[it.position.step]
}
fun goTo(position: ShowPosition) {
val step = show.acts[position]
stateFlow.update {
when {
step.cue is StepCue.MusicStart -> {
it.copy(
position = position,
activeMusic = ActiveShowMusic(
title = step.cue.title,
duration = step.cue.duration
),
musicStartTime = Clock.System.now()
)
}
step.cue is StepCue.MusicEnd ||
step.position == ShowPosition.START -> it.copy(position = position, activeMusic = null)
else -> it.copy(position = position)
}
}
if (step.cue is StepCue.MusicStart) {
logger.info("Music started: ${step.cue.title} (${step.cue.duration.inWholeSeconds}s)")
}
}
}
suspend fun runShow(show: Show, devices: ImmutableSet<Device>) = coroutineScope {
val logger = KotlinLogging.logger("runShow")
val stateFlow = MutableStateFlow(
ShowState(
ShowPosition.START,
"",
null,
Instant.DISTANT_PAST
)
)
val context = ShowContext(
devices,
show,
logger,
stateFlow,
AtomicInteger(0)
)
startDataWriting(context)
startStepRunning(context)
startWebsocketServer(context)
launch {
stateFlow.collect {
logger.info("ShowState changed: $it")
}
}
}
fun CoroutineScope.startDataWriting(context: ShowContext) = launch {
while (isActive) {
val took = measureTimeMillis {
val data = UByteArray(512)
val writer = PerDeviceDmxDataWriter(data)
for (device in context.devices) {
writer.reset(0, device.firstChannel, device.numberOfChannels)
device.writeDmxData(writer)
}
if (context.outputDataFreeze.get() == 0) EnttecOpenDmxUsb.data = data
}
delay(((1000.0 / 30) - took).roundToLong())
}
}
fun CoroutineScope.startStepRunning(context: ShowContext) = launch {
var lastPosition = ShowPosition.START
var lastStepJob: Job? = null
context.stateFlow.map { it.position }.distinctUntilChanged().collect { position ->
context.outputDataFreeze.incrementAndGet()
if (position < lastPosition) {
context.devices.forEach { it.dvs.forEach(DynamicValue<*>::reset) }
lastPosition = ShowPosition.START
}
while (lastPosition != position) {
lastPosition = context.show.getNextValidPosition(lastPosition) ?: break
val step = context.show.acts[lastPosition]
lastStepJob?.cancelAndJoin()
lastStepJob = launch(SupervisorJob(currentCoroutineContext().job)) {
val runContext = object : StepRunContext, CoroutineScope by this {}
step.runner?.let { runContext.it() }
}
}
context.outputDataFreeze.decrementAndGet()
}
}
@Serializable
data class StateUpdateMessage(
@Serializable(with = InstantAsEpochMillisecondsSerializer::class)
val timestamp: Instant,
val state: ShowState
)
private fun CoroutineScope.startWebsocketServer(context: ShowContext) = launch(Dispatchers.IO) {
val updateMessageJsonFlow = context.stateFlow.map { state ->
Json.encodeToString(
StateUpdateMessage(
Clock.System.now(),
state
)
)
}
val showJson = Json.encodeToString(context.show)
embeddedServer(CIO, port = 8000) {
install(WebSockets) {
pingPeriod = 10.seconds.toJavaDuration()
timeout = 15.seconds.toJavaDuration()
maxFrameSize = Long.MAX_VALUE
}
install(ContentNegotiation) {
json()
}
routing {
route("/api") {
post("/go") {
val position = call.receive<ShowPosition>()
context.goTo(position)
call.respond(HttpStatusCode.NoContent)
}
post("/message") {
val message = call.receiveText()
context.stateFlow.update { it.copy(message = message) }
call.respond(HttpStatusCode.NoContent)
}
get("/show") {
call.respond(context.show.acts.toMutableList())
}
get("/state") {
call.respond(context.stateFlow.value)
}
val clientsCount = AtomicInteger(0)
webSocket("/ws") {
var count = clientsCount.incrementAndGet()
context.logger.info("[${call.request.local.remoteHost}] connected ($count)")
val sendJob = launch {
send(Frame.Text(showJson))
updateMessageJsonFlow.collect { send(Frame.Text(it)) }
}
try {
for (frame in incoming) {
context.logger.debug { "[${call.request.local.remoteHost}] frame: $frame" }
}
} finally {
sendJob.cancel()
count = clientsCount.decrementAndGet()
context.logger.info("[${call.request.local.remoteHost}] disconnected ($count)")
}
}
}
}
}.start(wait = true)
}

View file

@ -0,0 +1,6 @@
package de.moritzruth.theaterdsl.show
import kotlinx.serialization.Serializable
@Serializable
class Scene(val name: String, val steps: List<Step>)

View file

@ -0,0 +1,65 @@
package de.moritzruth.theaterdsl.show
import de.moritzruth.theaterdsl.util.DurationAsMillisecondsSerializer
import de.moritzruth.theaterdsl.util.InstantAsEpochMillisecondsSerializer
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlin.time.Duration
@Serializable
data class Show(val acts: List<Act>) {
fun getNextValidPosition(position: ShowPosition): ShowPosition? {
if (position == ShowPosition.START) return ShowPosition.FIRST
var nextStep = position.step + 1
var nextScene = position.scene
var nextAct = position.act
if (nextStep >= acts[position.act].scenes[position.scene].steps.size) {
nextStep = 0
nextScene++
if (nextScene >= acts[position.act].scenes.size) {
nextScene = 0
nextAct++
if (nextAct >= acts.size) {
return null
}
}
}
return ShowPosition(nextAct, nextScene, nextStep)
}
}
@Serializable
data class ShowPosition(val act: Int, val scene: Int, val step: Int) : Comparable<ShowPosition> {
companion object {
val START = ShowPosition(-1, 0, 0)
val FIRST = ShowPosition(0, 0, 0)
}
override fun compareTo(other: ShowPosition): Int {
return if (act == other.act) {
if (scene == other.scene) step.compareTo(other.step)
else scene.compareTo(other.scene)
} else act.compareTo(other.act)
}
}
@Serializable
data class ShowState(
val position: ShowPosition,
val message: String,
val activeMusic: ActiveShowMusic?,
@Serializable(with = InstantAsEpochMillisecondsSerializer::class)
val musicStartTime: Instant
)
@Serializable
data class ActiveShowMusic(
val title: String,
@Serializable(with = DurationAsMillisecondsSerializer::class)
val duration: Duration
)

View file

@ -0,0 +1,48 @@
package de.moritzruth.theaterdsl.show
import de.moritzruth.theaterdsl.util.StringWithDetails
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
enum class PropPosition {
PROSCENIUM_LEFT,
PROSCENIUM_CENTER,
PROSCENIUM_RIGHT,
LEFT,
CENTER,
RIGHT,
BACKDROP
}
@TheaterDslMarker
interface StepRunContext: CoroutineScope
typealias StepRunner = (StepRunContext.() -> Unit)
@Serializable
class Step(
val position: ShowPosition,
val cue: StepCue,
val actorEntrances: Set<StringWithDetails>,
val actorExits: Set<StringWithDetails>,
val actorsOnStage: List<String>,
val props: Map<PropPosition, StringWithDetails?>,
val hasChangedProps: Boolean,
@Transient
val runner: StepRunner? = null
) {
companion object {
val START = Step(
ShowPosition.START,
StepCue.Custom("Start"),
emptySet(),
emptySet(),
emptyList(),
emptyMap(),
false,
null
)
}
}

View file

@ -0,0 +1,63 @@
package de.moritzruth.theaterdsl.show
import de.moritzruth.theaterdsl.util.DurationAsMillisecondsSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Duration
@Serializable
sealed interface StepCue {
fun format(): String
@Serializable
@SerialName("TEXT")
data class Text(val speaker: String, val text: String, val clarification: String? = null) : StepCue {
override fun format() = "text: $speaker: $text"
}
@Serializable
@SerialName("MUSIC_START")
data class MusicStart(val title: String, val duration: @Serializable(with = DurationAsMillisecondsSerializer::class) Duration) : StepCue {
override fun format() = "music start: $title"
}
@Serializable
@SerialName("MUSIC_END")
object MusicEnd : StepCue {
override fun format() = "music end"
}
@Serializable
@SerialName("CURTAIN")
data class Curtain(val state: State, val whileMoving: Boolean) : StepCue {
enum class State {
@SerialName("open")
OPEN,
@SerialName("closed")
CLOSED
}
override fun format() = "curtain: $state${if (whileMoving) "(while moving)" else ""}"
}
@Serializable
@SerialName("LIGHT")
data class Light(val state: State, val whileFading: Boolean) : StepCue {
enum class State {
@SerialName("on")
ON,
@SerialName("off")
OFF
}
override fun format() = "light: $state${if (whileFading) "(while fading)" else ""}"
}
@Serializable
@SerialName("CUSTOM")
data class Custom(val text: String) : StepCue {
override fun format() = "custom: $text"
}
}

View file

@ -0,0 +1,37 @@
package de.moritzruth.theaterdsl.util
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
// from https://github.com/Kotlin/kotlinx.coroutines/issues/2631#issuecomment-870565860
class DerivedStateFlow<T>(
private val getValue: () -> T,
private val flow: Flow<T>
) : StateFlow<T> {
override val replayCache: List<T>
get() = listOf(value)
override val value: T
get() = getValue()
@InternalCoroutinesApi
override suspend fun collect(collector: FlowCollector<T>): Nothing {
coroutineScope { flow.distinctUntilChanged().stateIn(this).collect(collector) }
}
}
fun <T1, R> StateFlow<T1>.mapState(transform: (a: T1) -> R): StateFlow<R> {
return DerivedStateFlow(
getValue = { transform(this.value) },
flow = this.map { a -> transform(a) }
)
}
fun <T1, T2, R> combineStates(flow: StateFlow<T1>, flow2: StateFlow<T2>, transform: (a: T1, b: T2) -> R): StateFlow<R> {
return DerivedStateFlow(
getValue = { transform(flow.value, flow2.value) },
flow = combine(flow, flow2) { a, b -> transform(a, b) }
)
}

View file

@ -0,0 +1,20 @@
package de.moritzruth.theaterdsl.util
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.serializer
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object DurationAsMillisecondsSerializer : KSerializer<Duration> {
override val descriptor: SerialDescriptor = serializer<Duration>().descriptor
override fun deserialize(decoder: Decoder): Duration =
decoder.decodeLong().milliseconds
override fun serialize(encoder: Encoder, value: Duration) {
encoder.encodeLong(value.inWholeMilliseconds)
}
}

View file

@ -0,0 +1,19 @@
package de.moritzruth.theaterdsl.util
import kotlinx.datetime.Instant
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.serializer
object InstantAsEpochMillisecondsSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor = serializer<Instant>().descriptor
override fun deserialize(decoder: Decoder): Instant =
Instant.fromEpochMilliseconds(decoder.decodeLong())
override fun serialize(encoder: Encoder, value: Instant) {
encoder.encodeLong(value.toEpochMilliseconds())
}
}

View file

@ -0,0 +1,18 @@
package de.moritzruth.theaterdsl.util
import kotlinx.serialization.Serializable
/**
* A string which has optional additional details separated by " / "
*/
@JvmInline
@Serializable
value class StringWithDetails(val value: String) {
companion object {
const val DELIMITER = " / "
}
val main get() = value.split(DELIMITER)[0]
val details: String? get() = value.split(DELIMITER, limit = 2).takeIf { it.size == 2 }?.let { it[1] }
val hasDetails get() = details !== null
}

View file

@ -0,0 +1 @@
org.slf4j.simpleLogger.defaultLogLevel=debug

View file

@ -12,13 +12,9 @@ version.com.fazecast..jSerialComm=2.9.3
version.io.github.oshai..kotlin-logging-jvm=4.0.0-beta-28
version.kotlin=1.8.20
version.kotlinx.collections.immutable=0.3.5
version.kotlinx.coroutines=1.7.1
version.kotlinx.datetime=0.4.0
version.kotlinx.serialization=1.5.1
version.ktor=2.3.0
version.org.slf4j..slf4j-simple=2.0.7