Initial commit.

This commit is contained in:
2025-06-17 03:58:48 +08:00
commit 9b1c988365
26 changed files with 2221 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import com.vanniktech.maven.publish.SonatypeHost
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.vanniktech.mavenPublish)
kotlin("plugin.serialization") version "2.1.20"
id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.17.0"
}
group = "cn.rdlevel"
version = "0.1.0"
kotlin {
// jvm
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
// android
androidTarget {
publishLibraryVariants("release")
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
// native
// t1
macosX64()
macosArm64()
iosSimulatorArm64()
iosX64()
iosArm64()
// t2
linuxX64()
linuxArm64()
watchosSimulatorArm64()
watchosX64()
watchosArm32()
watchosArm64()
tvosSimulatorArm64()
tvosX64()
tvosArm64()
// t3
androidNativeArm32()
androidNativeArm64()
androidNativeX86()
androidNativeX64()
mingwX64()
watchosDeviceArm64()
// js
js {
browser { }
nodejs { }
binaries.executable()
}
// wasm
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
nodejs()
d8()
binaries.executable()
}
@OptIn(ExperimentalWasmDsl::class)
wasmWasi {
nodejs()
binaries.executable()
}
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
}
}
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
}
explicitApi()
}
android {
namespace = "cn.rdlevel.rdkt"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
mavenPublishing {
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
signAllPublications()
coordinates(group.toString(), "rdkt-core", version.toString())
pom {
name = "rdkt"
description = "A library to write Rhythm Doctor levels on Kotlin."
inceptionYear = "2025"
url = "https://github.com/RDCN-Community-Developers/rdkt/"
licenses {
license {
name = "XXX"
url = "YYY"
distribution = "ZZZ"
}
}
developers {
developer {
id = "XXX"
name = "YYY"
url = "ZZZ"
}
}
scm {
url = "XXX"
connection = "YYY"
developerConnection = "ZZZ"
}
}
}
@@ -0,0 +1,11 @@
package cn.rdlevel.rdkt.core
import cn.rdlevel.rdkt.core.settings.LevelSettings
import kotlinx.serialization.Serializable
@Serializable
public data class RDLevel(
public val settings: LevelSettings,
) {
}
@@ -0,0 +1,21 @@
package cn.rdlevel.rdkt.core.annotations
/**
* This annotation marks APIs that are intended for internal use within the rdkt library.
*
* It is not recommended for general use and may change without notice.
*/
@MustBeDocumented
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.TYPEALIAS,
AnnotationTarget.CONSTRUCTOR,
)
@Retention(AnnotationRetention.BINARY)
@RequiresOptIn(
"This API is intended for internal use within the rdkt library and may change without notice. Use with caution.",
RequiresOptIn.Level.WARNING,
)
public annotation class RDKTInternalAPI
@@ -0,0 +1,431 @@
@file:OptIn(RDKTInternalAPI::class)
@file:JvmName("SelectedRoomsUtils")
package cn.rdlevel.rdkt.core.data
import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI
import cn.rdlevel.rdkt.core.serializers.TransformSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.SetSerializer
import kotlinx.serialization.builtins.serializer
import kotlin.jvm.JvmName
import kotlin.jvm.JvmStatic
import kotlin.jvm.JvmSynthetic
import kotlin.reflect.KClass
/**
* Represents a collection of selected rooms, including the top layer.
*
* All implementations of this interface must contain at least one room, or the top layer.
* Failing to do so will result in an exception.
*/
@Serializable(SelectedRoomsAndTopLayer.Serializer::class)
public sealed interface SelectedRoomsAndTopLayer {
/**
* The room IDs that are selected.
*/
public val rooms: Set<Int>
/**
* Whether the selected rooms contain a room with the given [id].
*
* @param id The ID of the room to check.
*/
public fun containsRoom(id: Int): Boolean {
return id in rooms
}
/**
* Whether the selected rooms contain the top layer room.
*/
public fun containsTop(): Boolean {
return TOP_LAYER in rooms
}
public operator fun plus(other: SelectedRoomsAndTopLayer): SelectedRoomsAndTopLayer {
return of(rooms + other.rooms)
}
private class Impl(override val rooms: Set<Int>) : AbstractSelectedRoomsAndTopLayer(), SelectedRoomsAndTopLayer {
init {
requireNotEmpty()
requireAllRoomsOrTopLayer()
}
}
public companion object {
/**
* Creates a new instance of [SelectedRoomsAndTopLayer].
*
* @param rooms The set of selected room IDs.
* @return A new instance of [SelectedRoomsAndTopLayer] with the specified room IDs.
*/
@JvmStatic
public fun of(rooms: Set<Int>): SelectedRoomsAndTopLayer {
return Impl(rooms)
}
/**
* Creates a new instance of [SelectedRoomsAndTopLayer] with the specified room IDs.
*
* @param room The ID of the first selected room.
* @param rooms Additional room IDs to include in the selection.
* @return A new instance of [SelectedRoomsAndTopLayer] containing the specified room IDs.
*/
@JvmStatic
public fun of(room: Int, vararg rooms: Int): SelectedRoomsAndTopLayer {
return of(setOf(room) + rooms.toSet())
}
}
public object Serializer :
TransformSerializer<SelectedRoomsAndTopLayer, Set<Int>>(SetSerializer(Int.serializer())) {
override fun toData(value: SelectedRoomsAndTopLayer): Set<Int> {
return value.rooms
}
override fun fromData(data: Set<Int>): SelectedRoomsAndTopLayer {
return of(data)
}
}
}
public operator fun SelectedRoomsAndTopLayer.contains(id: Int): Boolean {
return containsRoom(id)
}
private fun SelectedRoomsAndTopLayer.requireNotEmpty() {
require(rooms.isNotEmpty()) { "Selected rooms must not be empty." }
}
private fun SelectedRoomsAndTopLayer.requireAllRoomsOrTopLayer() {
require(rooms.all { it in ROOM1..TOP_LAYER }) {
"Selected rooms must only contain room IDs from 0 to 3 or the top layer."
}
}
private fun SelectedRoomsAndTopLayer.requireAllRooms() {
require(rooms.all { it in ROOM1..ROOM4 }) {
"Selected rooms must only contain room IDs from 0 to 3."
}
}
private sealed class AbstractSelectedRoomsAndTopLayer : SelectedRoomsAndTopLayer {
override operator fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SelectedRoomsAndTopLayer) return false
return rooms == other.rooms
}
override fun hashCode(): Int {
return rooms.hashCode()
}
}
/**
* Represents a collection of either normal rooms or the top layer.
*/
@Serializable(SelectedRoomsOrTopLayer.Serializer::class)
public sealed interface SelectedRoomsOrTopLayer : SelectedRoomsAndTopLayer {
public object Serializer :
TransformSerializer<SelectedRoomsOrTopLayer, Set<Int>>(SetSerializer(Int.serializer())) {
override fun toData(value: SelectedRoomsOrTopLayer): Set<Int> {
return value.rooms
}
override fun fromData(data: Set<Int>): SelectedRoomsOrTopLayer {
return roomsOf(data)
}
}
}
/**
* Represents a collection of either a normal room or the top layer.
*/
@Serializable(SingleSelectedRoomOrTopLayer.Serializer::class)
public sealed interface SingleSelectedRoomOrTopLayer : SelectedRoomsOrTopLayer {
/**
* The single selected room ID.
*/
public val room: Int
override val rooms: Set<Int>
get() = setOf(room)
override fun containsRoom(id: Int): Boolean {
return room == id
}
override fun containsTop(): Boolean {
return room == TOP_LAYER
}
public object Serializer :
TransformSerializer<SingleSelectedRoomOrTopLayer, Set<Int>>(SetSerializer(Int.serializer())) {
override fun toData(value: SingleSelectedRoomOrTopLayer): Set<Int> {
return value.rooms
}
override fun fromData(data: Set<Int>): SingleSelectedRoomOrTopLayer {
return roomsOf(data)
}
}
}
/**
* Represents a collection of selected rooms, not including the top layer.
*/
@Serializable(SelectedRooms.Serializer::class)
public sealed interface SelectedRooms : SelectedRoomsOrTopLayer {
public operator fun plus(other: SelectedRooms): SelectedRooms {
return of(rooms + other.rooms)
}
private class Impl(override val rooms: Set<Int>) : AbstractSelectedRoomsAndTopLayer(), SelectedRooms {
init {
requireNotEmpty()
requireAllRooms()
}
}
public companion object {
/**
* Creates a new instance of [SelectedRooms].
*
* @param rooms The set of selected room IDs.
* @return A new instance of [SelectedRooms] with the specified room IDs.
*/
@JvmStatic
public fun of(rooms: Set<Int>): SelectedRooms {
return Impl(rooms)
}
/**
* Creates a new instance of [SelectedRooms] with the specified room IDs.
*
* @param room The ID of the first selected room.
* @param rooms Additional room IDs to include in the selection.
* @return A new instance of [SelectedRooms] containing the specified room IDs.
*/
@JvmStatic
public fun of(room: Int, vararg rooms: Int): SelectedRooms {
return of(setOf(room) + rooms.toSet())
}
}
public object Serializer :
TransformSerializer<SelectedRooms, Set<Int>>(SetSerializer(Int.serializer())) {
override fun toData(value: SelectedRooms): Set<Int> {
return value.rooms
}
override fun fromData(data: Set<Int>): SelectedRooms {
return of(data)
}
}
}
/**
* Represents the selected top layer.
*/
@Serializable(SelectedTopLayer.Serializer::class)
public sealed interface SelectedTopLayer : SingleSelectedRoomOrTopLayer {
override val room: Int
get() = TOP_LAYER
private object Impl : AbstractSelectedRoomsAndTopLayer(), SelectedTopLayer
public companion object {
/**
* Gets the instance of [SelectedTopLayer].
*
* @return The instance of [SelectedTopLayer].
*/
@JvmStatic
public fun of(): SelectedTopLayer {
return Impl
}
}
public object Serializer :
TransformSerializer<SelectedTopLayer, Set<Int>>(SetSerializer(Int.serializer())) {
override fun toData(value: SelectedTopLayer): Set<Int> {
return value.rooms
}
override fun fromData(data: Set<Int>): SelectedTopLayer {
require(TOP_LAYER in data && data.size == 1) { "SelectedTopLayer must be created with the top layer ID." }
return of()
}
}
}
/**
* Represents a single selected room, not including the top layer.
*/
@Serializable(SingleSelectedRoom.Serializer::class)
public sealed interface SingleSelectedRoom : SingleSelectedRoomOrTopLayer, SelectedRooms {
private class Impl(override val room: Int) : AbstractSelectedRoomsAndTopLayer(), SingleSelectedRoom {
init {
requireAllRooms()
}
}
public companion object {
/**
* Creates a new instance of [SingleSelectedRoom].
*
* @param room The ID of the selected room.
* @return A new instance of [SingleSelectedRoom] with the specified room ID.
*/
@JvmStatic
public fun of(room: Int): SingleSelectedRoom {
return Impl(room)
}
}
public object Serializer :
TransformSerializer<SingleSelectedRoom, Set<Int>>(SetSerializer(Int.serializer())) {
override fun toData(value: SingleSelectedRoom): Set<Int> {
return value.rooms
}
override fun fromData(data: Set<Int>): SingleSelectedRoom {
require(data.size == 1) { "SingleSelectedRoom must contain exactly one room." }
return of(data.first())
}
}
}
/**
* The ID of the first room.
*/
public const val ROOM1: Int = 0
/**
* The ID of the second room.
*/
public const val ROOM2: Int = 1
/**
* The ID of the third room.
*/
public const val ROOM3: Int = 2
/**
* The ID of the fourth room.
*/
public const val ROOM4: Int = 3
/**
* The ID of the top layer.
*/
public const val TOP_LAYER: Int = 4
/**
* Creates an instance of [SelectedRoomsAndTopLayer] based on the provided [KClass] and set of room IDs.
*
* @param T The type of [SelectedRoomsAndTopLayer] to create.
* @param kClass The [KClass] of the type to create.
* @param rooms The set of room IDs to include in the selection.
* @return An instance of [SelectedRoomsAndTopLayer] of the specified type.
*/
@JvmSynthetic
@RDKTInternalAPI
public fun <T : SelectedRoomsAndTopLayer> roomsOf(kClass: KClass<T>, rooms: Set<Int>): T {
@Suppress("UNCHECKED_CAST")
return when (kClass) {
SelectedRoomsAndTopLayer::class -> SelectedRoomsAndTopLayer.of(rooms)
SelectedRoomsOrTopLayer::class -> {
when {
TOP_LAYER in rooms && rooms.size > 1 -> error("Cannot have both top layer and other rooms selected.")
TOP_LAYER in rooms -> SelectedTopLayer.of()
else -> SelectedRooms.of(rooms)
}
}
SingleSelectedRoomOrTopLayer::class -> {
require(rooms.size == 1) { "SingleSelectedRoomOrTopLayer must contain exactly one room or the top layer." }
when {
TOP_LAYER in rooms -> SelectedTopLayer.of()
else -> SingleSelectedRoom.of(rooms.first())
}
}
SelectedRooms::class -> SelectedRooms.of(rooms)
SelectedTopLayer::class -> {
require(rooms.size == 1 && TOP_LAYER in rooms) { "SelectedTopLayer must contain only the top layer." }
SelectedTopLayer.of()
}
SingleSelectedRoom::class -> {
require(rooms.size == 1) { "SingleSelectedRoom must contain exactly one room." }
SingleSelectedRoom.of(rooms.first())
}
else -> error("Unsupported type: ${kClass.simpleName}")
} as T
}
/**
* Creates an instance of [SelectedRoomsAndTopLayer] based on the provided [KClass] and a single room ID.
*
* @param T The type of [SelectedRoomsAndTopLayer] to create.
* @param kClass The [KClass] of the type to create.
* @param room The ID of the room to include in the selection.
* @param rooms Additional room IDs to include in the selection.
* @return An instance of [SelectedRoomsAndTopLayer] of the specified type.
*/
@JvmSynthetic
@RDKTInternalAPI
public fun <T : SelectedRoomsAndTopLayer> roomsOf(kClass: KClass<T>, room: Int, vararg rooms: Int): T {
return roomsOf(kClass, setOf(room) + rooms.toSet())
}
/**
* Creates an instance of [SingleSelectedRoomOrTopLayer] based on the provided [KClass] and a single room ID.
*
* @param T The type of [SingleSelectedRoomOrTopLayer] to create.
* @param kClass The [KClass] of the type to create.
* @param room The ID of the room to include in the selection.
* @return An instance of [SingleSelectedRoomOrTopLayer] of the specified type.
*/
@JvmSynthetic
@RDKTInternalAPI
public fun <T : SingleSelectedRoomOrTopLayer> singleRoomOf(kClass: KClass<T>, room: Int): T {
return roomsOf(kClass, room)
}
/**
* Creates an instance of [SelectedRoomsAndTopLayer] based on the provided set of room IDs.
*
* @param T The type of [SelectedRoomsAndTopLayer] to create.
* @param rooms The set of room IDs to include in the selection.
* @return An instance of [SelectedRoomsAndTopLayer] of the specified type.
*/
public inline fun <reified T : SelectedRoomsAndTopLayer> roomsOf(rooms: Set<Int>): T {
return roomsOf(T::class, rooms)
}
/**
* Creates an instance of [SelectedRoomsAndTopLayer] based on the provided room ID and additional room IDs.
*
* @param T The type of [SelectedRoomsAndTopLayer] to create.
* @param room The ID of the first selected room.
* @param rooms Additional room IDs to include in the selection.
* @return An instance of [SelectedRoomsAndTopLayer] of the specified type.
*/
public inline fun <reified T : SelectedRoomsAndTopLayer> roomsOf(room: Int, vararg rooms: Int): T {
return roomsOf(T::class, room, *rooms)
}
/**
* Creates an instance of [SingleSelectedRoomOrTopLayer] based on the provided room ID.
*
* @param T The type of [SingleSelectedRoomOrTopLayer] to create.
* @param room The ID of the selected room.
* @return An instance of [SingleSelectedRoomOrTopLayer] of the specified type.
*/
public inline fun <reified T : SingleSelectedRoomOrTopLayer> singleRoomOf(room: Int): T {
return singleRoomOf(T::class, room)
}
@@ -0,0 +1,4 @@
package cn.rdlevel.rdkt.core.events
public interface Event {
}
@@ -0,0 +1,41 @@
package cn.rdlevel.rdkt.core.serializers
import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* A serializer for subtypes that delegates serialization and deserialization to a [baseSerializer] of a base type.
*
* The [baseSerializer] is responsible for handling the serialization and deserialization logic,
* and returns the deserialized value as the correct subtype of [Base] for possible subtype implementations of this class.
*
* @param Base The base type that the [baseSerializer] will handle.
* @param Sub The subtype that extends [Base] and will be serialized/deserialized.
*/
@RDKTInternalAPI
@SubclassOptInRequired(RDKTInternalAPI::class)
public abstract class PolymorphicDelegatedSerializer<Sub : Base, Base>(
/**
* The base serializer that handles the serialization and deserialization logic.
*/
protected val baseSerializer: KSerializer<Base>,
) : KSerializer<Sub> {
override val descriptor: SerialDescriptor
get() = baseSerializer.descriptor
override fun serialize(encoder: Encoder, value: Sub) {
encoder.encodeSerializableValue(baseSerializer, value)
}
override fun deserialize(decoder: Decoder): Sub {
val result = decoder.decodeSerializableValue(baseSerializer)
runCatching {
@Suppress("UNCHECKED_CAST")
return result as Sub
}
error("Unable to cast deserialized value to specified type. Check your base serializer.")
}
}
@@ -0,0 +1,56 @@
@file:OptIn(RDKTInternalAPI::class)
package cn.rdlevel.rdkt.core.serializers
import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* A serializer for a list of strings that serializes the list as a single string, separated by commas.
*/
@RDKTInternalAPI
public object StringListAsStringSerializer : KSerializer<List<String>> {
override val descriptor: SerialDescriptor = String.serializer().descriptor
override fun serialize(encoder: Encoder, value: List<String>) {
encoder.encodeString(value.joinToString(", "))
}
override fun deserialize(decoder: Decoder): List<String> {
return decoder.decodeString()
.split(", ")
}
}
/**
* A list of strings that is serialized as a single string, separated by commas.
*/
public typealias StringListSerializedInString = @Serializable(StringListAsStringSerializer::class) List<String>
/**
* A serializer for a mutable list of strings that serializes the list as a single string, separated by commas.
*/
@RDKTInternalAPI
public object MutableStringListAsStringSerializer : KSerializer<MutableList<String>> {
override val descriptor: SerialDescriptor = String.serializer().descriptor
override fun serialize(encoder: Encoder, value: MutableList<String>) {
encoder.encodeString(value.joinToString(", "))
}
override fun deserialize(decoder: Decoder): MutableList<String> {
return decoder.decodeString()
.split(", ")
.toMutableList()
}
}
/**
* A mutable list of strings that is serialized as a single string, separated by commas.
*/
public typealias MutableStringListSerializedInString = @Serializable(MutableStringListAsStringSerializer::class) MutableList<String>
@@ -0,0 +1,50 @@
package cn.rdlevel.rdkt.core.serializers
import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* A base serializer for types that can be transformed to and from a specific data representation.
*
* This serializer provides a common implementation for serializing and deserializing types
* that can be converted to and from a specific data representation defined by [Data].
*
* @param T The type to be serialized/deserialized.
* @param Data The data representation of the type.
*/
@RDKTInternalAPI
public abstract class TransformSerializer<T, Data>(
private val dataSerializer: KSerializer<Data>
) : KSerializer<T> {
override val descriptor: SerialDescriptor
get() = dataSerializer.descriptor
override fun serialize(encoder: Encoder, value: T) {
val data = toData(value)
encoder.encodeSerializableValue(dataSerializer, data)
}
override fun deserialize(decoder: Decoder): T {
val data = decoder.decodeSerializableValue(dataSerializer)
return fromData(data)
}
/**
* Converts the given value to its data representation.
*
* @param value The value to convert.
* @return The data representation of the value.
*/
protected abstract fun toData(value: T): Data
/**
* Converts the given data representation back to the original type.
*
* @param data The data representation to convert.
* @return The original type represented by the data.
*/
protected abstract fun fromData(data: Data): T
}
@@ -0,0 +1,61 @@
package cn.rdlevel.rdkt.core.settings
import cn.rdlevel.rdkt.core.settings.CustomClasses.INJURY
/**
* Some available custom classes for custom levels in the game.
* Data mainly from [Rhythm Doctor Custom Method Directory](https://docs.google.com/spreadsheets/d/1JAz6iRLqcn08ZeTeBHeeDrpdX6M5K0b1qRVQomua21s).
*
* Put one of them in [LevelSettings.customClass] to enable it.
*/
public object CustomClasses {
/**
* Adds baseballs and animations in Baseball Stadium.
* Used in 5-1N.
*/
public const val INJURY: String = "Injury"
/**
* Simulates part of level 1-2.
*/
public const val INTIMATE: String = "Intimate"
/**
* Works like [INJURY], but additionally enables scoreboard, score count, and some custom methods.
* Used in 5-1.
*/
public const val LUCKY_BREAK: String = "LuckyBreak"
/**
* Uses a slightly different desert theme.
* Used in 4-1N.
*/
public const val ROLLERDISCO: String = "Rollerdisco"
/**
* Replaces hearts to ones seen in X-WOT.
* Requires at least 5 rows in the level to work.
*/
public const val UNBEATABLE: String = "Unbeatable"
/**
* Forces the S+ rank description to be "ALL CRITICAL".
* Enables the "VRankHacky()" custom method.
* Used in X-FTS.
*/
public const val VIVID_STASIS: String = "VividStasis"
/**
* Enables alternative "Spaaaaace!" theme, audio reactive row glows with custom methods.
* Used in 2-2N.
*/
public const val UNREACHABLE: String = "Unreachable"
/**
* Replaces hearts with pumpkins.
* Currently, this is broken and does not work.
*/
@Deprecated("This custom class is currently broken and does not work.")
public const val HALLOWEEN: String = "Halloween"
}
@@ -0,0 +1,118 @@
package cn.rdlevel.rdkt.core.settings
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the special type of the permission on the song used in the level.
*/
@Serializable
public enum class SpecialArtistType {
/**
* No special type of permission.
*/
@SerialName("None")
NONE,
/**
* The artist is the author of the level.
*/
@SerialName("AuthorIsArtist")
AUTHOR_IS_ARTIST,
/**
* The song is under a public license, such as Creative Commons or Public Domain.
*/
@SerialName("PublicLicense")
PUBLIC_LICENSE,
}
/**
* Represents the difficulty of a level.
*/
@Serializable
public enum class LevelDifficulty {
/**
* Easy difficulty level.
*/
@SerialName("Easy")
EASY,
/**
* Medium difficulty level.
*/
@SerialName("Medium")
MEDIUM,
/**
* Tough difficulty level.
*/
@SerialName("Tough")
TOUGH,
/**
* Very tough difficulty level.
*/
@SerialName("VeryTough")
VERY_TOUGH,
}
/**
* Represents whether a level can be played in one player, two players or both.
*/
@Serializable
public enum class LevelPlayerMode {
/**
* The level can only be played in one player mode.
*/
@SerialName("OnePlayerOnly")
ONE_PLAYER_ONLY,
/**
* The level can only be played in two player mode.
*/
@SerialName("TwoPlayerOnly")
TWO_PLAYER_ONLY,
/**
* The level can be played in both one player and two player modes.
*/
@SerialName("BothModes")
BOTH_MODES,
}
/**
* Represents the execute behavior of the events on bar 1, beat 1, in a level.
*/
@Serializable
public enum class FirstBeatBehavior {
/**
* The events on bar 1, beat 1 will be executed normally after the level starts.
*/
@SerialName("RunNormally")
RUN_NORMALLY,
/**
* The events on bar 1, beat 1 will be executed before the level starts.
*/
@SerialName("RunEventsOnPrebar")
RUN_EVENTS_ON_PREBAR,
}
/**
* Represents the appearance of horizontal strips in multiplayer mode.
*/
@Serializable
public enum class MultiplayerAppearance {
/**
* Horizontal strips will be shown in multiplayer mode.
*/
@SerialName("HorizontalStrips")
HORIZONTAL_STRIPS,
/**
* Horizontal strips will not be shown in multiplayer mode.
*/
@SerialName("Nothing")
NOTHING,
}
@@ -0,0 +1,221 @@
@file:JvmName("LevelSettingsUtils")
package cn.rdlevel.rdkt.core.settings
import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI
import cn.rdlevel.rdkt.core.serializers.MutableStringListSerializedInString
import cn.rdlevel.rdkt.core.settings.LevelSettings.Companion.CURRENT_LEVEL_VERSION
import kotlinx.serialization.Serializable
import kotlin.jvm.JvmName
import kotlin.random.Random
/**
* Represents the settings of a level.
*/
@OptIn(RDKTInternalAPI::class)
@Serializable
public class LevelSettings {
/**
* The version of the level. Currently, it is set to [61][CURRENT_LEVEL_VERSION].
*
* This library currently does not support levels with other versions.
* If you want to use levels with other versions, please use the level editor to convert them to a supported version.
*
* Changing this version number to a not supported one will throw an exception.
*/
public var version: Int = CURRENT_LEVEL_VERSION
set(value) {
require(value == CURRENT_LEVEL_VERSION) {
"""
Levels with version $value is not supported.
Currently, the supported level version is $CURRENT_LEVEL_VERSION.
If you want to use levels with other versions, please use the level editor to convert them to a supported version.
""".trimIndent()
}
field = value
}
/**
* The artist of the song used in the level.
*/
public var artist: String = ""
/**
* The song used in the level.
*/
public var song: String = ""
/**
* The special artist permission type for the use of the song.
*/
public var specialArtistType: SpecialArtistType = SpecialArtistType.NONE
/**
* The artist permission, which is usually a file name to the permission file.
*/
public var artistPermission: String = ""
/**
* The links to the artist's social media or other relevant sites.
*/
public var artistLinks: MutableStringListSerializedInString = mutableListOf()
/**
* The ones who created or made the level.
*/
public var author: String = ""
/**
* The level difficulty.
*/
public var difficulty: LevelDifficulty = LevelDifficulty.EASY
/**
* Whether the level contains intensive visual effects that may cause seizures.
*
* Usually it can be determined by whether it contains a one-second period that has 3 sudden high contrast changes.
*
* For more information, see [this article](https://www.w3.org/WAI/WCAG21/Understanding/seizures-and-physical-reactions.html).
*/
public var seizureWarning: Boolean = false
/**
* The level preview image file name.
*/
public var previewImage: String = ""
/**
* The level syringe icon file name.
*/
public var syringeIcon: String = ""
/**
* The level preview song file name.
*
* This is usually a short clip of the song used in the level.
*/
public var previewSong: String = ""
/**
* The start time of the preview song in seconds. Must be non-negative.
*/
public var previewSongStartTime: Double = 0.0
set(value) {
require(value >= 0.0) { "Preview song start time must be non-negative." }
field = value
}
/**
* The duration of the preview song in seconds. Must be non-negative.
*/
public var previewSongDuration: Double = 10.0
set(value) {
require(value >= 0.0) { "Preview song duration must be non-negative." }
field = value
}
/**
* The color hue for the syringe strip of the level. Must be between 0.0 and 1.0.
*/
public var songNameHue: Double = Random.nextDouble()
set(value) {
require(value in 0.0..1.0) { "Song name hue must be between 0.0 and 1.0." }
field = value
}
/**
* Whether the use grayscale for the syringe strip.
*/
public var songLabelGrayscale: Boolean = false
/**
* Level description.
*/
public var description: String = ""
/**
* The level tags. Fill this with tags that are relevant to the level so that it can be searched easily.
*/
public var tags: MutableStringListSerializedInString = mutableListOf()
/**
* The separate level file name for multiplayer mode.
* Leave blank if the level is the same for both one player and two players.
*/
public var separate2PLevelFilename: String = ""
/**
* Whether the level can be played in one player only, two players only, or can be played in both mode.
*/
public var canBePlayedOn: LevelPlayerMode = LevelPlayerMode.ONE_PLAYER_ONLY
/**
* Determines how the events on the first beat is executed.
*/
public var firstBeatBehavior: FirstBeatBehavior = FirstBeatBehavior.RUN_NORMALLY
/**
* Determines how appearances of the multiplayer mode are displayed.
*/
public var multiplayerAppearance: MultiplayerAppearance = MultiplayerAppearance.HORIZONTAL_STRIPS
/**
* The volume of the level. Must be between 0.0 and 1.0.
*/
public var levelVolume: Double = 1.0
set(value) {
require(value in 0.0..1.0) { "Level volume must be between 0.0 and 1.0." }
field = value
}
/**
* The maximum number of mistakes allowed in the level for each rank.
*/
public var rankMaxMistakes: RankMaxMistakes = RankMaxMistakes()
/**
* The rank descriptions for the level.
*/
public var rankDescription: RankDescriptions = RankDescriptions()
/**
* The modifications that are enabled for the level.
* See [Mods] for available modifications.
*/
public var mods: MutableList<String> = mutableListOf()
/**
* The custom class name for the level.
* See [CustomClasses] for available custom classes.
*
* This is used to specify a custom class that can be used to handle the level in a special way.
* It is not recommended to use this unless you know what you are doing.
*/
public var customClass: String? = null
public companion object {
/**
* The current level version the level editor is using.
*/
public const val CURRENT_LEVEL_VERSION: Int = 61
}
}
/**
* Configures the preview song for the level.
*
* @param fileName The file name of the preview song.
* @param startTime The start time of the preview song in seconds. Defaults to 0.0.
* @param duration The duration of the preview song in seconds. Defaults to 10.0.
* @return The updated [LevelSettings] instance.
*/
public fun LevelSettings.previewSong(
fileName: String,
startTime: Double = 0.0,
duration: Double = 10.0
): LevelSettings {
previewSong = fileName
previewSongStartTime = startTime
previewSongDuration = duration
return this
}
@@ -0,0 +1,45 @@
package cn.rdlevel.rdkt.core.settings
import cn.rdlevel.rdkt.core.settings.Mods.OLD_BASS_DROP
/**
* Some available modifications for custom levels in the game.
* Data mainly from [Rhythm Doctor Custom Method Directory](https://docs.google.com/spreadsheets/d/1JAz6iRLqcn08ZeTeBHeeDrpdX6M5K0b1qRVQomua21s).
*
* Put them in [LevelSettings.mods] to enable them.
*/
public object Mods {
/**
* Enables bomb beats, a type of beat which cause player to take damage if they hit it.
*/
public const val BOMB_BEATS: String = "bombBeats"
/**
* Makes hit particles stay still and not change size with the row.
*/
public const val CLASSIC_HIT_PARTICLES: String = "classicHitParticles"
/**
* Make bass drop animation more gentle.
* Requires [OLD_BASS_DROP] to work.
*/
public const val GENTLE_BASS_DROP: String = "gentleBassDrop"
/**
* Use old bass drop animation.
* Disables the intensity option in Bass Drop event.
*/
public const val OLD_BASS_DROP: String = "oldBassDrop"
/**
* Level starts immediately without waiting for the player to press the space bar after loading.
*/
public const val START_IMMEDIATELY: String = "startImmediately"
/**
* Makes the level skippable via the skip button in the pause menu.
* If the player skips the level, the level immediately ends and the player is given an S rank.
*/
public const val SKIPPABLE: String = "skippable"
}
@@ -0,0 +1,327 @@
@file:OptIn(RDKTInternalAPI::class)
@file:JvmName("Rankings")
package cn.rdlevel.rdkt.core.settings
import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.jvm.JvmName
/**
* Represents the maximum number of mistakes allowed for each rank in a level.
*/
@Serializable(RankMaxMistakes.Serializer::class)
public data class RankMaxMistakes(
/**
* The maximum number of mistakes allowed for rank A.
*/
public val rankA: Int = DEFAULT_RANK_A,
/**
* The maximum number of mistakes allowed for rank B.
*/
public val rankB: Int = DEFAULT_RANK_B,
/**
* The maximum number of mistakes allowed for rank C.
*/
public val rankC: Int = DEFAULT_RANK_C,
/**
* The maximum number of mistakes allowed for rank D.
*/
public val rankD: Int = DEFAULT_RANK_D,
) {
init {
require(
listOf(
DEFAULT_RANK_S <= rankA,
rankA <= rankB,
rankB <= rankC,
rankC <= rankD,
rankD <= DEFAULT_RANK_F,
).all { it }
) {
"Invalid rank max mistakes: S <= A <= B <= C <= D <= F must hold true. " +
"Got: S=$DEFAULT_RANK_S, A=$rankA, B=$rankB, C=$rankC, D=$rankD"
}
}
public companion object {
/**
* The rank S, which is the highest rank, and always has no mistakes allowed.
*/
public const val DEFAULT_RANK_S: Int = 0
/**
* The default allowed mistakes for rank A.
*/
public const val DEFAULT_RANK_A: Int = 5
/**
* The default allowed mistakes for rank B.
*/
public const val DEFAULT_RANK_B: Int = 10
/**
* The default allowed mistakes for rank C.
*/
public const val DEFAULT_RANK_C: Int = 15
/**
* The default allowed mistakes for rank D.
*/
public const val DEFAULT_RANK_D: Int = 20
/**
* The rank F, which is the lowest rank, and always has any number of mistakes allowed.
*/
public const val DEFAULT_RANK_F: Int = Int.MAX_VALUE
}
public object Serializer : KSerializer<RankMaxMistakes> {
private val listSerializer = ListSerializer(Int.Companion.serializer())
override val descriptor: SerialDescriptor = listSerializer.descriptor
override fun serialize(encoder: Encoder, value: RankMaxMistakes) {
encoder.encodeSerializableValue(
listSerializer, listOf(
value.rankD,
value.rankC,
value.rankB,
value.rankA,
)
)
}
override fun deserialize(decoder: Decoder): RankMaxMistakes {
val list = decoder.decodeSerializableValue(listSerializer)
return RankMaxMistakes(
rankA = list.getOrElse(3) { DEFAULT_RANK_A },
rankB = list.getOrElse(2) { DEFAULT_RANK_B },
rankC = list.getOrElse(1) { DEFAULT_RANK_C },
rankD = list.getOrElse(0) { DEFAULT_RANK_D },
)
}
}
}
/**
* Represents the description for each rank in a level.
*/
@Serializable
public data class RankDescriptions(
/**
* Rank S description.
*/
public val rankS: String = DEFAULT_RANK_S,
/**
* Rank A description.
*/
public val rankA: String = DEFAULT_RANK_A,
/**
* Rank B description.
*/
public val rankB: String = DEFAULT_RANK_B,
/**
* Rank C description.
*/
public val rankC: String = DEFAULT_RANK_C,
/**
* Rank D description.
*/
public val rankD: String = DEFAULT_RANK_D,
/**
* Rank F description.
*/
public val rankF: String = DEFAULT_RANK_F,
) {
public companion object {
/**
* The default description for rank S.
*/
public const val DEFAULT_RANK_S: String = "Wow! That's awesome!!"
/**
* The default description for rank A.
*/
public const val DEFAULT_RANK_A: String = "You are really good!"
/**
* The default description for rank B.
*/
public const val DEFAULT_RANK_B: String = "We make a good team!"
/**
* The default description for rank C.
*/
public const val DEFAULT_RANK_C: String = "Not bad I guess..."
/**
* The default description for rank D.
*/
public const val DEFAULT_RANK_D: String = "Ugh, you can do better"
/**
* The default description for rank F.
*/
public const val DEFAULT_RANK_F: String = "Better call 911, now!"
}
public object Serializer : KSerializer<RankDescriptions> {
private val listSerializer = ListSerializer(String.serializer())
override val descriptor: SerialDescriptor = listSerializer.descriptor
override fun serialize(encoder: Encoder, value: RankDescriptions) {
encoder.encodeSerializableValue(
listSerializer, listOf(
value.rankF,
value.rankD,
value.rankC,
value.rankB,
value.rankA,
value.rankS,
)
)
}
override fun deserialize(decoder: Decoder): RankDescriptions {
val list = decoder.decodeSerializableValue(listSerializer)
return RankDescriptions(
rankS = list.getOrElse(5) { DEFAULT_RANK_S },
rankA = list.getOrElse(4) { DEFAULT_RANK_A },
rankB = list.getOrElse(3) { DEFAULT_RANK_B },
rankC = list.getOrElse(2) { DEFAULT_RANK_C },
rankD = list.getOrElse(1) { DEFAULT_RANK_D },
rankF = list.getOrElse(0) { DEFAULT_RANK_F },
)
}
}
}
/**
* A builder for creating rank settings in a level.
* This is an internal API and instances should not be created directly.
*/
public class RankBuilder @RDKTInternalAPI constructor() {
/**
* Represents a rank configuration with the number of mistakes allowed and a description.
* This interface can only modify the description of the rank.
*/
public sealed interface Rank {
/**
* The max number of mistakes allowed for this rank.
*/
public val mistakes: Int
/**
* The description for this rank.
*/
public var description: String
/**
* Sets the description for this rank.
*
* @param description The description for the rank.
* @return The current [Rank] instance for chaining.
*/
public infix fun description(description: String): Rank {
this.description = description
return this
}
}
/**
* Represents a rank configuration that allows both the number of mistakes and the description to be modified.
*/
public sealed interface MistakesMutableRank : Rank {
override var mistakes: Int
/**
* Sets the number of mistakes allowed for this rank.
*
* @param mistakes The number of mistakes allowed.
* @return The current [Rank] instance for chaining.
*/
public infix fun mistakes(mistakes: Int): Rank {
this.mistakes = mistakes
return this
}
}
private class RankImpl(
override var mistakes: Int = 0,
override var description: String = "",
) : Rank, MistakesMutableRank
/**
* Rank S configuration.
*/
public val rankS: Rank = RankImpl(RankMaxMistakes.DEFAULT_RANK_S, RankDescriptions.DEFAULT_RANK_S)
/**
* Rank A configuration.
*/
public val rankA: MistakesMutableRank = RankImpl(RankMaxMistakes.DEFAULT_RANK_A, RankDescriptions.DEFAULT_RANK_A)
/**
* Rank B configuration.
*/
public val rankB: MistakesMutableRank = RankImpl(RankMaxMistakes.DEFAULT_RANK_B, RankDescriptions.DEFAULT_RANK_B)
/**
* Rank C configuration.
*/
public val rankC: MistakesMutableRank = RankImpl(RankMaxMistakes.DEFAULT_RANK_C, RankDescriptions.DEFAULT_RANK_C)
/**
* Rank D configuration.
*/
public val rankD: MistakesMutableRank = RankImpl(RankMaxMistakes.DEFAULT_RANK_D, RankDescriptions.DEFAULT_RANK_D)
/**
* Rank F configuration.
*/
public val rankF: Rank = RankImpl(RankMaxMistakes.DEFAULT_RANK_F, RankDescriptions.DEFAULT_RANK_F)
/**
* Applies the rank settings to the provided [LevelSettings].
*
* @param settings The level settings to apply the rank settings to.
*/
public fun apply(settings: LevelSettings) {
settings.rankMaxMistakes = RankMaxMistakes(
rankA = rankA.mistakes,
rankB = rankB.mistakes,
rankC = rankC.mistakes,
rankD = rankD.mistakes,
)
settings.rankDescription = RankDescriptions(
rankS = rankS.description,
rankA = rankA.description,
rankB = rankB.description,
rankC = rankC.description,
rankD = rankD.description,
rankF = rankF.description,
)
}
}
/**
* Configure ranks in a [LevelSettings] instance.
*
* @param block The block to configure the ranks.
* @return The updated [LevelSettings] instance with the configured ranks.
*/
public inline fun LevelSettings.ranks(block: RankBuilder.() -> Unit): LevelSettings {
val builder = RankBuilder()
block(builder)
builder.apply(this)
return this
}