diff --git a/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/data/OneshotFreezeBurn.kt b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/data/OneshotFreezeBurn.kt new file mode 100644 index 0000000..6bddd67 --- /dev/null +++ b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/data/OneshotFreezeBurn.kt @@ -0,0 +1,46 @@ +package cn.rdlevel.rdkt.core.data + +import kotlin.jvm.JvmStatic + +/** + * Represents the freeze or burn modifier applied to a oneshot pulse. + * Used in [cn.rdlevel.rdkt.core.events.AddOneshotBeatEvent]. + */ +public sealed class OneshotFreezeBurn(public val type: String) { + /** + * Neither freeze nor burn modifier is applied to the oneshot pulse. + */ + public data object None : OneshotFreezeBurn("None") + + /** + * The oneshot pulse is frozen and the actual hit point is delayed for a certain duration. + * + * @param delay The duration of the delay in beats. + */ + public data class Freezeshot(val delay: Double) : OneshotFreezeBurn("Freezeshot") + + /** + * The oneshot pulse is burned and the actual hit point is advanced by a certain duration. + */ + public data object Burnshot : OneshotFreezeBurn("Burnshot") + + public companion object { + /** + * Returns an instance of [None], indicating that neither freeze nor burn modifier is applied to the oneshot pulse. + */ + @JvmStatic + public fun none(): None = None + + /** + * Returns an instance of [Freezeshot] with the specified delay, indicating that the oneshot pulse is frozen and the actual hit point is delayed for the given duration. + */ + @JvmStatic + public fun freezeshot(delay: Double): Freezeshot = Freezeshot(delay) + + /** + * Returns an instance of [Burnshot], indicating that the oneshot pulse is burned and the actual hit point is advanced by a certain duration. + */ + @JvmStatic + public fun burnshot(): Burnshot = Burnshot + } +} \ No newline at end of file diff --git a/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/data/OneshotHold.kt b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/data/OneshotHold.kt new file mode 100644 index 0000000..baab79f --- /dev/null +++ b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/data/OneshotHold.kt @@ -0,0 +1,63 @@ +package cn.rdlevel.rdkt.core.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmOverloads +import kotlin.jvm.JvmStatic + +/** + * Represents the hold settings of a oneshot pulse. + * Used in [cn.rdlevel.rdkt.core.events.AddOneshotBeatEvent]. + */ +public sealed class OneshotHold { + /** + * The oneshot pulse is not holdshot. + */ + public data object Off : OneshotHold() + + /** + * The oneshot pulse is holdshot. + * + * @property cueType The type of the holdshot cue. + */ + public data class On(var cueType: CueType = CueType.AUTO) : OneshotHold() + + /** + * Represents when the holdshot cue is cued. + */ + @Serializable + public enum class CueType { + /** + * The time of holdshot cue is automatically determined. + */ + @SerialName("Auto") + AUTO, + + /** + * The cue is cued on the end of the previous oneshot. + */ + @SerialName("Early") + EARLY, + + /** + * The cue is cued on the start of the pulse of the holdshot. + */ + @SerialName("Late") + LATE, + } + + public companion object { + /** + * Returns an instance of [Off], indicating that the oneshot pulse is not holdshot. + */ + @JvmStatic + public fun off(): Off = Off + + /** + * Returns an instance of [On] with the specified cue type, indicating that the oneshot pulse is holdshot with the given cue type. + */ + @JvmStatic + @JvmOverloads + public fun on(cueType: CueType = CueType.AUTO): On = On(cueType) + } +} \ No newline at end of file diff --git a/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/data/OneshotPulse.kt b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/data/OneshotPulse.kt new file mode 100644 index 0000000..2e60a10 --- /dev/null +++ b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/data/OneshotPulse.kt @@ -0,0 +1,99 @@ +package cn.rdlevel.rdkt.core.data + +import kotlin.jvm.JvmOverloads +import kotlin.jvm.JvmStatic + +/** + * Represents a oneshot pulse with its settings. + * Used in [cn.rdlevel.rdkt.core.events.AddOneshotBeatEvent]. + */ +public sealed class OneshotPulse( + public val pulseType: String, +) { + /** + * The number of subdivisions for the pulse. Must be between 1 and 10. + * For normal waves this is defaulted to 1. + */ + public abstract val subdivisions: Int + + /** + * Represent a oneshot that can have subdivisions and sound settings. + * + * @property sound Whether the pulse should have special sounds. Default is true. + */ + public sealed class OneshotSubdivisionPulse( + pulseType: String, + public var sound: Boolean = true + ) : OneshotPulse(pulseType) + + /** + * Represents a simple oneshot pulse without subdivisions or sound settings. + */ + public object Wave : OneshotPulse("Wave") { + override val subdivisions: Int + get() = 1 + } + + /** + * Represents a heart-shaped oneshot pulse without subdivisions or sound settings. + */ + public object Heart : OneshotPulse("Heart") { + override val subdivisions: Int + get() = 1 + } + + /** + * Represents a oneshot pulse with a single subdivision (squareshot) and sound settings. + */ + public class Square(sound: Boolean) : OneshotSubdivisionPulse("Square", sound) { + override val subdivisions: Int + get() = 1 + } + + /** + * Represents a oneshot pulse with multiple subdivisions (triangleshot) and sound settings. + */ + public class Triangle(subdivisions: Int, sound: Boolean) : OneshotSubdivisionPulse("Triangle", sound) { + override var subdivisions: Int = subdivisions + set(value) { + require(value in 1..10) { "Subdivisions must be between 1 and 10." } + field = value + } + + init { + this.subdivisions = subdivisions + } + } + + public companion object { + /** + * Returns a [Wave] oneshot pulse, which has no subdivisions or sound settings. + */ + @JvmStatic + public fun wave(): Wave = Wave + + /** + * Returns a [Heart] oneshot pulse, which has no subdivisions or sound settings. + */ + public fun heart(): Heart = Heart + + /** + * Returns either a [Square] or a [Triangle] based on the number of subdivisions. + */ + @JvmStatic + @JvmOverloads + public fun subdivision(subdivisions: Int, sound: Boolean = true): OneshotSubdivisionPulse = + when (subdivisions) { + 1 -> Square(sound) + in 2..10 -> Triangle(subdivisions, sound) + else -> throw IllegalArgumentException("Subdivisions must be between 1 and 10.") + } + + internal fun from(type: String, subdivisions: Int, sound: Boolean): OneshotPulse = when (type) { + "Wave" -> wave() + "Square" -> subdivision(1, sound) + "Triangle" -> subdivision(subdivisions, sound) + else -> throw IllegalArgumentException("Unknown pulse type: $type") + } + } +} \ No newline at end of file diff --git a/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/events/AddOneshotBeatEvent.kt b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/events/AddOneshotBeatEvent.kt new file mode 100644 index 0000000..dab92fe --- /dev/null +++ b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/events/AddOneshotBeatEvent.kt @@ -0,0 +1,141 @@ +@file:OptIn(RDKTInternalAPI::class) + +package cn.rdlevel.rdkt.core.events + + +import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI +import cn.rdlevel.rdkt.core.data.OneshotFreezeBurn +import cn.rdlevel.rdkt.core.data.OneshotHold +import cn.rdlevel.rdkt.core.data.OneshotPulse +import cn.rdlevel.rdkt.core.serialization.TransformSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmStatic + +/** + * Adds a oneshot pulse to the specific row. + * + * @property pulse The type of the oneshot pulse to be added. + * @property freezeBurnMode The freeze or burn modifier applied to the oneshot pulse. + * @property hold The hold behavior of the oneshot pulse. + * @property skip Whether the oneshot pulse is a skipshot. + */ +@Serializable(AddOneshotBeatEvent.Serializer::class) +@SerialName("AddOneshotBeat") +public data class AddOneshotBeatEvent( + var pulse: OneshotPulse = OneshotPulse.Wave, + var freezeBurnMode: OneshotFreezeBurn = OneshotFreezeBurn.None, + var hold: OneshotHold = OneshotHold.Off, + var skip: Boolean = false, +) : RowEvent() { + /** + * The time between the pulse appears and the actual hit point in beats. + * Must be non-negative. + */ + var tick: Double = 1.0 + set(value) { + require(value >= 0) { "Tick must be non-negative." } + field = value + } + + /** + * The interval between every two hits with normal looped oneshots in beats. + * Must be non-negative. + * This value is only used if this is a skipshot, freezeshot, burnshot, holdshot, or [loops] is positive. + */ + var interval: Double = 2.0 + set(value) { + require(value >= 0) { "Interval must be non-negative." } + field = value + } + + /** + * How many repetitions of the oneshot pattern will create. Must be non-negative. + * Setting this to 0 means the oneshot will only occur once. + */ + var loops: Int = 0 + set(value) { + require(value >= 0) { "Loops must be non-negative." } + field = value + } + + public object Serializer : + TransformSerializer("AddOneshotBeat", Data.serializer()) { + override fun toData(value: AddOneshotBeatEvent): Data { + return Data.fromBase(value).apply { + pulseType = value.pulse.pulseType + freezeBurnMode = value.freezeBurnMode.type + interval = value.interval + tick = value.tick + delay = (value.freezeBurnMode as? OneshotFreezeBurn.Freezeshot)?.delay + loops = value.loops + subdivisions = value.pulse.subdivisions + subdivSound = (value.pulse as? OneshotPulse.OneshotSubdivisionPulse)?.sound + skipshot = value.skip + hold = value.hold is OneshotHold.On + holdCue = (value.hold as? OneshotHold.On)?.cueType + } + } + + override fun fromData(data: Data): AddOneshotBeatEvent { + val pulse = OneshotPulse.from(data.pulseType, data.subdivisions, data.subdivSound ?: true) + val freezeBurnMode = when (data.freezeBurnMode) { + "None", null -> OneshotFreezeBurn.None + "Freezeshot" -> OneshotFreezeBurn.Freezeshot( + data.delay ?: error("Delay must be provided for Freezeshot mode.") + ) + + "Burnshot" -> OneshotFreezeBurn.Burnshot + else -> throw IllegalArgumentException("Unknown freeze/burn mode: ${data.freezeBurnMode}") + } + val hold = if (data.hold == true) { + OneshotHold.On(data.holdCue ?: OneshotHold.CueType.AUTO) + } else { + OneshotHold.Off + } + return AddOneshotBeatEvent( + pulse = pulse, + freezeBurnMode = freezeBurnMode, + hold = hold, + skip = data.skipshot ?: false, + ).apply { + copyBaseFrom(data) + this.interval = data.interval + this.tick = data.tick + this.loops = data.loops + } + } + + @Serializable + @RDKTInternalAPI + public data class Data( + var pulseType: String = "", + var freezeBurnMode: String? = null, + var interval: Double = 2.0, + var tick: Double = 1.0, + var delay: Double? = 0.5, + var loops: Int = 0, + var subdivisions: Int = 0, + var subdivSound: Boolean? = true, + var skipshot: Boolean? = false, + var hold: Boolean? = false, + var holdCue: OneshotHold.CueType? = OneshotHold.CueType.AUTO, + ) : RowEvent() { + public companion object { + public fun fromBase(other: RowEvent): Data { + return Data().apply { copyBaseFrom(other) } + } + } + } + } + + public companion object { + /** + * Creates a new [AddOneshotBeatEvent] using the provided block to configure its properties. + */ + @JvmStatic + public fun of(block: AddOneshotBeatEvent.() -> Unit): AddOneshotBeatEvent { + return AddOneshotBeatEvent().apply(block) + } + } +} \ No newline at end of file