From dd69f06346cf824de76f4e56cf2b8aa7b2997c1d Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 6 Dec 2025 05:07:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=93=E3=83=AB=E3=83=89=E9=80=9A?= =?UTF-8?q?=E3=82=89=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 +- .../net/hareworks/npc-mannequin/Plugin.kt | 24 -- .../net/hareworks/npc-mannequin/Plugin.kt | 28 ++ .../commands/MannequinCommands.kt | 368 ++++++++++++++++++ .../mannequin/MannequinSettings.kt | 165 ++++++++ .../service/MannequinController.kt | 57 +++ .../service/MannequinRegistry.kt | 121 ++++++ .../npc-mannequin/storage/MannequinStorage.kt | 139 +++++++ .../npc-mannequin/text/TextSerializers.kt | 19 + 9 files changed, 899 insertions(+), 26 deletions(-) delete mode 100644 main/kotlin/net/hareworks/npc-mannequin/Plugin.kt create mode 100644 src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt create mode 100644 src/main/kotlin/net/hareworks/npc-mannequin/commands/MannequinCommands.kt create mode 100644 src/main/kotlin/net/hareworks/npc-mannequin/mannequin/MannequinSettings.kt create mode 100644 src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinController.kt create mode 100644 src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinRegistry.kt create mode 100644 src/main/kotlin/net/hareworks/npc-mannequin/storage/MannequinStorage.kt create mode 100644 src/main/kotlin/net/hareworks/npc-mannequin/text/TextSerializers.kt diff --git a/build.gradle.kts b/build.gradle.kts index f9583ac..090afbc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,8 +14,8 @@ repositories { dependencies { compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") implementation("org.jetbrains.kotlin:kotlin-stdlib") - implementation("net.hareworks:kommand-lib:1.1") - implementation("net.hareworks:permits-lib:1.1") + implementation("net.hareworks:kommand-lib") + implementation("net.hareworks:permits-lib") implementation("net.kyori:adventure-text-minimessage:4.17.0") implementation("net.kyori:adventure-text-serializer-plain:4.17.0") } diff --git a/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt b/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt deleted file mode 100644 index 03367b0..0000000 --- a/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt +++ /dev/null @@ -1,24 +0,0 @@ -package net.hareworks.npc_mannequin - -import net.hareworks.kommand_lib.KommandLib -import net.hareworks.permits_lib.PermitsLib -import org.bukkit.plugin.ServicePriority -import org.bukkit.plugin.java.JavaPlugin - -class GhostDisplaysPlugin : JavaPlugin() { - private var permissionSession: MutationSession? = null - private var kommand: KommandLib? = null - - override fun onEnable() { - instance = this - } - - override fun onDisable() { - instance = null - } - - companion object { - @Volatile - private var instance: GhostDisplaysPlugin? = null - } -} diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt b/src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt new file mode 100644 index 0000000..8c22825 --- /dev/null +++ b/src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt @@ -0,0 +1,28 @@ +package net.hareworks.npc_mannequin + +import net.hareworks.kommand_lib.KommandLib +import net.hareworks.npc_mannequin.commands.MannequinCommands +import net.hareworks.npc_mannequin.service.MannequinController +import net.hareworks.npc_mannequin.service.MannequinRegistry +import net.hareworks.npc_mannequin.storage.MannequinStorage +import org.bukkit.plugin.ServicePriority +import org.bukkit.plugin.java.JavaPlugin + +class Plugin : JavaPlugin() { + private var kommand: KommandLib? = null + private lateinit var registry: MannequinRegistry + + override fun onEnable() { + val storage = MannequinStorage(this) + val controller = MannequinController(this) + registry = MannequinRegistry(this, storage, controller) + kommand = MannequinCommands(this, registry).register() + server.servicesManager.register(MannequinRegistry::class.java, registry, this, ServicePriority.Normal) + logger.info("Loaded ${registry.all().size} mannequin definitions.") + } + + override fun onDisable() { + server.servicesManager.unregisterAll(this) + kommand?.unregister() + } +} diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/commands/MannequinCommands.kt b/src/main/kotlin/net/hareworks/npc-mannequin/commands/MannequinCommands.kt new file mode 100644 index 0000000..9a9e571 --- /dev/null +++ b/src/main/kotlin/net/hareworks/npc-mannequin/commands/MannequinCommands.kt @@ -0,0 +1,368 @@ +package net.hareworks.npc_mannequin.commands + +import net.hareworks.kommand_lib.KommandLib +import net.hareworks.kommand_lib.context.KommandContext +import net.hareworks.kommand_lib.kommand +import net.hareworks.npc_mannequin.mannequin.MannequinHiddenLayer +import net.hareworks.npc_mannequin.mannequin.MannequinSettings +import net.hareworks.npc_mannequin.mannequin.StoredLocation +import net.hareworks.npc_mannequin.mannequin.StoredProfile +import net.hareworks.npc_mannequin.service.MannequinRegistry +import net.hareworks.npc_mannequin.text.TextSerializers +import net.hareworks.permits_lib.PermitsLib +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.entity.Entity +import org.bukkit.entity.Mannequin +import org.bukkit.entity.Player +import org.bukkit.entity.Pose +import org.bukkit.inventory.MainHand +import org.bukkit.plugin.java.JavaPlugin +import java.util.Locale + +class MannequinCommands( + private val plugin: JavaPlugin, + private val registry: MannequinRegistry +) { + fun register(): KommandLib = kommand(plugin) { + permissions { + namespace = "hareworks" + rootSegment = "command" + defaultDescription { ctx -> + "Allows /${ctx.commandName} ${ctx.path.joinToString(" ")}" + } + session { PermitsLib.session(it) } + } + + command("mannequin", listOf("mnpc", "mnq", "npcmannequin")) { + description = "Register and manage mannequin NPCs" + permission = "hareworks.command.mannequin" + + executes { listMannequins(registry) } + + literal("list") { + executes { listMannequins(registry) } + } + + literal("register") { + string("id") { + selector("target") { + executes { + val id = argument("id") + val entity = argument>("target").firstOrNull { it is Mannequin } as? Mannequin + if (entity == null) { + error("Selector must target at least one mannequin entity.") + return@executes + } + runCatching { + registry.register(id, entity, overwrite = false) + }.onSuccess { + success("Registered mannequin '$id' from entity ${entity.uniqueId}.") + }.onFailure { + error(it.message ?: "Failed to register mannequin.") + } + } + literal("--overwrite") { + executes { + val id = argument("id") + val entity = argument>("target").firstOrNull { it is Mannequin } as? Mannequin + if (entity == null) { + error("Selector must target at least one mannequin entity.") + return@executes + } + registry.register(id, entity, overwrite = true) + success("Replaced mannequin '$id' with entity ${entity.uniqueId}.") + } + } + } + } + } + + literal("create") { + string("id") { + executes { + val player = requirePlayer() ?: return@executes + val id = argument("id") + runCatching { + registry.create(id, player.location, MannequinSettings()) + }.onSuccess { + success("Spawned mannequin '$id' at ${formatLocation(it.location)}.") + }.onFailure { + error(it.message ?: "Failed to create mannequin.") + } + } + } + } + + literal("move") { + string("id") { + executes { + val player = requirePlayer() ?: return@executes + val id = argument("id") + runCatching { + registry.relocate(id, player.location) + }.onSuccess { + success("Updated location of '$id' to ${formatLocation(it.location)}.") + }.onFailure { + error(it.message ?: "Failed to move mannequin.") + } + } + } + } + + literal("apply") { + string("id") { + executes { + val id = argument("id") + runCatching { + registry.apply(id, spawnIfMissing = true) + ?: throw IllegalStateException("Mannequin '$id' is not spawned and has no stored position.") + }.onSuccess { + success("Applied stored settings to '$id'.") + }.onFailure { + error(it.message ?: "Failed to apply mannequin settings.") + } + } + } + } + + literal("remove") { + string("id") { + executes { + val id = argument("id") + runCatching { registry.remove(id, deleteEntity = false) } + .onSuccess { success("Removed mannequin '$id' but kept the entity in the world.") } + .onFailure { error(it.message ?: "Failed to remove mannequin.") } + } + literal("--delete-entity") { + executes { + val id = argument("id") + runCatching { registry.remove(id, deleteEntity = true) } + .onSuccess { success("Removed mannequin '$id' and deleted its entity.") } + .onFailure { error(it.message ?: "Failed to remove mannequin.") } + } + } + } + } + + literal("set") { + string("id") { + literal("pose") { + string("pose") { + suggests { prefix -> Pose.entries.map { it.name.lowercase() }.filter { it.startsWith(prefix.lowercase()) } } + executes { + val id = argument("id") + val poseToken = argument("pose") + val pose = runCatching { Pose.valueOf(poseToken.uppercase()) }.getOrNull() + if (pose == null) { + error("Unknown pose '$poseToken'.") + return@executes + } + registry.updateSettings(id) { it.copy(pose = pose) } + success("Updated pose for '$id' to ${pose.name.lowercase()}.") + } + } + } + + literal("mainhand") { + string("hand") { + suggests { prefix -> MainHand.entries.map { it.name.lowercase() }.filter { it.startsWith(prefix.lowercase()) } } + executes { + val id = argument("id") + val handToken = argument("hand") + val mainHand = runCatching { MainHand.valueOf(handToken.uppercase()) }.getOrNull() + if (mainHand == null) { + error("Unknown hand '$handToken'.") + return@executes + } + registry.updateSettings(id) { it.copy(mainHand = mainHand) } + success("Updated main hand for '$id' to ${mainHand.name.lowercase()}.") + } + } + } + + literal("immovable") { + string("state") { + suggests { prefix -> listOf("true", "false", "on", "off").filter { it.startsWith(prefix.lowercase()) } } + executes { + val id = argument("id") + val input = argument("state") + val state = parseBoolean(input) + if (state == null) { + error("Value must be true/false/on/off.") + return@executes + } + registry.updateSettings(id) { it.copy(immovable = state) } + success("Set immovable for '$id' to $state.") + } + } + } + + literal("description") { + literal("text") { + executes { + val id = argument("id") + val payload = remainingInput(DESCRIPTION_TEXT_OFFSET) + if (payload.isNullOrBlank()) { + error("Provide MiniMessage text after the command, e.g. /mannequin set $id description text ") + return@executes + } + val component = runCatching { TextSerializers.miniMessage(payload) } + .onFailure { error("MiniMessage parse failed: ${it.message}") } + .getOrNull() + if (component == null) { + return@executes + } + registry.updateSettings(id) { it.copy(description = component, hideDescription = false) } + success("Updated description for '$id'.") + } + } + literal("clear") { + executes { + val id = argument("id") + registry.updateSettings(id) { it.copy(description = null, hideDescription = false) } + success("Cleared custom description for '$id'.") + } + } + literal("hide") { + string("state") { + suggests { prefix -> listOf("true", "false", "on", "off").filter { it.startsWith(prefix.lowercase()) } } + executes { + val id = argument("id") + val input = argument("state") + val state = parseBoolean(input) + if (state == null) { + error("Value must be true/false/on/off.") + return@executes + } + registry.updateSettings(id) { it.copy(hideDescription = state) } + success(if (state) "Description hidden for '$id'." else "Description visible for '$id'.") + } + } + } + } + + literal("layers") { + literal("hide") { + string("layer") { + suggests { prefix -> layerSuggestions(prefix) } + executes { + val id = argument("id") + val layerName = argument("layer") + val layer = MannequinHiddenLayer.fromKey(layerName) + if (layer == null) { + error("Unknown layer '$layerName'.") + return@executes + } + registry.updateSettings(id) { it.copy(hiddenLayers = it.hiddenLayers + layer) } + success("Hid layer ${layer.key} for '$id'.") + } + } + } + literal("show") { + string("layer") { + suggests { prefix -> layerSuggestions(prefix) } + executes { + val id = argument("id") + val layerName = argument("layer") + val layer = MannequinHiddenLayer.fromKey(layerName) + if (layer == null) { + error("Unknown layer '$layerName'.") + return@executes + } + registry.updateSettings(id) { it.copy(hiddenLayers = it.hiddenLayers - layer) } + success("Enabled layer ${layer.key} for '$id'.") + } + } + } + literal("clear") { + executes { + val id = argument("id") + registry.updateSettings(id) { it.copy(hiddenLayers = emptySet()) } + success("Cleared hidden layers for '$id'.") + } + } + } + + literal("profile") { + literal("player") { + player("source") { + executes { + val id = argument("id") + val source = argument("source") + val stored = StoredProfile.from(source.playerProfile) + registry.updateSettings(id) { it.copy(profile = stored) } + success("Copied profile from ${source.name} into '$id'.") + } + } + } + literal("clear") { + executes { + val id = argument("id") + registry.updateSettings(id) { it.copy(profile = null) } + success("Cleared stored profile for '$id'.") + } + } + } + } + } + } + } +} + +private const val DESCRIPTION_TEXT_OFFSET = 4 + +private fun KommandContext.listMannequins(registry: MannequinRegistry) { + val entries = registry.all() + if (entries.isEmpty()) { + sender.sendMessage(Component.text("No mannequins registered.", NamedTextColor.YELLOW)) + return + } + sender.sendMessage(Component.text("Registered mannequins (${entries.size}):", NamedTextColor.GRAY)) + entries.forEach { record -> + val status = if (registry.locate(record.id) != null) "active" else "offline" + val location = formatLocation(record.location) + sender.sendMessage( + Component.text("- ${record.id}: ", NamedTextColor.WHITE) + .append(Component.text(status, if (status == "active") NamedTextColor.GREEN else NamedTextColor.DARK_GRAY)) + .append(Component.text(" @ $location", NamedTextColor.GRAY)) + ) + } +} + +private fun KommandContext.requirePlayer(): Player? { + return sender as? Player ?: run { + sender.sendMessage(Component.text("This command can only be run by a player.", NamedTextColor.RED)) + null + } +} + +private fun KommandContext.success(message: String) { + sender.sendMessage(Component.text(message, NamedTextColor.GREEN)) +} + +private fun KommandContext.error(message: String) { + sender.sendMessage(Component.text(message, NamedTextColor.RED)) +} + +private fun KommandContext.remainingInput(offset: Int): String? { + if (args.size <= offset) return null + return args.drop(offset).joinToString(" ").trim().ifEmpty { null } +} + +private fun layerSuggestions(prefix: String): List = + MannequinHiddenLayer.entries.map { it.key }.filter { it.startsWith(prefix.lowercase()) } + +private fun parseBoolean(input: String): Boolean? = when (input.lowercase()) { + "true", "on", "yes", "1" -> true + "false", "off", "no", "0" -> false + else -> null +} + +private fun formatLocation(location: StoredLocation?): String = + location?.let { + val x = String.format(Locale.US, "%.2f", it.x) + val y = String.format(Locale.US, "%.2f", it.y) + val z = String.format(Locale.US, "%.2f", it.z) + "${it.world} ($x, $y, $z)" + } ?: "unknown location" diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/mannequin/MannequinSettings.kt b/src/main/kotlin/net/hareworks/npc-mannequin/mannequin/MannequinSettings.kt new file mode 100644 index 0000000..ad4d4b5 --- /dev/null +++ b/src/main/kotlin/net/hareworks/npc-mannequin/mannequin/MannequinSettings.kt @@ -0,0 +1,165 @@ +package net.hareworks.npc_mannequin.mannequin + +import com.destroystokyo.paper.SkinParts +import com.destroystokyo.paper.profile.PlayerProfile +import com.destroystokyo.paper.profile.ProfileProperty +import io.papermc.paper.datacomponent.item.ResolvableProfile +import net.kyori.adventure.text.Component +import org.bukkit.Location +import org.bukkit.entity.Mannequin +import org.bukkit.entity.Pose +import org.bukkit.inventory.MainHand +import java.util.UUID + +/** + * Immutable snapshot of a mannequin definition that can be persisted and replayed. + */ +data class MannequinSettings( + val pose: Pose = Pose.STANDING, + val mainHand: MainHand = MainHand.RIGHT, + val immovable: Boolean = false, + val description: Component? = null, + val hideDescription: Boolean = false, + val hiddenLayers: Set = emptySet(), + val profile: StoredProfile? = null +) { + companion object { + fun from(entity: Mannequin): MannequinSettings { + val skinParts = entity.skinParts + return MannequinSettings( + pose = entity.pose, + mainHand = entity.mainHand, + immovable = entity.isImmovable, + description = entity.description, + hideDescription = entity.description == Component.empty(), + hiddenLayers = MannequinHiddenLayer.fromSkinParts(skinParts), + profile = StoredProfile.from(entity.profile) + ) + } + } +} + +/** + * Named layers that can individually be hidden on top of the default mannequin skin. + */ +enum class MannequinHiddenLayer(val key: String) { + CAPE("cape"), + JACKET("jacket"), + LEFT_SLEEVE("left_sleeve"), + RIGHT_SLEEVE("right_sleeve"), + LEFT_PANTS("left_pants_leg"), + RIGHT_PANTS("right_pants_leg"), + HAT("hat"); + + companion object { + fun fromKey(input: String): MannequinHiddenLayer? = + entries.firstOrNull { it.key.equals(input, ignoreCase = true) } + + fun fromSkinParts(parts: SkinParts): Set { + val hidden = mutableSetOf() + if (!parts.hasCapeEnabled()) hidden += CAPE + if (!parts.hasJacketEnabled()) hidden += JACKET + if (!parts.hasLeftSleeveEnabled()) hidden += LEFT_SLEEVE + if (!parts.hasRightSleeveEnabled()) hidden += RIGHT_SLEEVE + if (!parts.hasLeftPantsEnabled()) hidden += LEFT_PANTS + if (!parts.hasRightPantsEnabled()) hidden += RIGHT_PANTS + if (!parts.hasHatsEnabled()) hidden += HAT + return hidden + } + } +} + +/** + * Serializable skeleton of a player/mannequin profile (skin/cape/model definition). + */ +data class StoredProfile( + val name: String?, + val uuid: UUID?, + val properties: List, +) { + fun toResolvable(): ResolvableProfile { + val builder = ResolvableProfile.resolvableProfile() + name?.let { builder.name(it) } + uuid?.let { builder.uuid(it) } + if (properties.isEmpty()) { + builder.addProperties(emptyList()) + } else { + properties.forEach { builder.addProperty(ProfileProperty(it.name, it.value, it.signature)) } + } + return builder.build() + } + + companion object { + fun from(profile: ResolvableProfile): StoredProfile { + val props = profile.properties() + .map { StoredProfileProperty(it.name, it.value, it.signature) } + return StoredProfile( + name = profile.name(), + uuid = profile.uuid(), + properties = props + ) + } + + fun from(profile: PlayerProfile): StoredProfile { + val props = profile.properties.map { StoredProfileProperty(it.name, it.value, it.signature) } + return StoredProfile( + name = profile.name, + uuid = profile.id, + properties = props + ) + } + } +} + +data class StoredProfileProperty( + val name: String, + val value: String, + val signature: String? +) + +/** + * Small serializable wrapper around a Bukkit location so we can persist mannequins across restarts. + */ +data class StoredLocation( + val world: String, + val x: Double, + val y: Double, + val z: Double, + val yaw: Float, + val pitch: Float +) { + fun toLocation(base: org.bukkit.Server): Location? { + val worldObj = base.getWorld(world) ?: return null + return Location(worldObj, x, y, z, yaw, pitch) + } + + companion object { + fun from(location: Location): StoredLocation = StoredLocation( + world = location.world?.name ?: throw IllegalStateException("Location has no world"), + x = location.x, + y = location.y, + z = location.z, + yaw = location.yaw, + pitch = location.pitch + ) + } +} + +/** + * Full registry entry containing the data snapshot and bookkeeping metadata. + */ +data class MannequinRecord( + val id: String, + val settings: MannequinSettings, + val location: StoredLocation?, + val entityId: UUID? +) { + fun updateSettings(next: MannequinSettings): MannequinRecord = + copy(settings = next) + + fun updateLocation(next: StoredLocation?): MannequinRecord = + copy(location = next) + + fun updateEntityId(next: UUID?): MannequinRecord = + copy(entityId = next) +} diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinController.kt b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinController.kt new file mode 100644 index 0000000..e6d89d5 --- /dev/null +++ b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinController.kt @@ -0,0 +1,57 @@ +package net.hareworks.npc_mannequin.service + +import com.destroystokyo.paper.SkinParts +import net.hareworks.npc_mannequin.mannequin.MannequinHiddenLayer +import net.hareworks.npc_mannequin.mannequin.MannequinRecord +import net.hareworks.npc_mannequin.mannequin.MannequinSettings +import net.kyori.adventure.text.Component +import org.bukkit.Location +import org.bukkit.entity.Mannequin +import org.bukkit.plugin.java.JavaPlugin + +class MannequinController(private val plugin: JavaPlugin) { + + fun extractSettings(entity: Mannequin): MannequinSettings = MannequinSettings.from(entity) + + fun applySettings(entity: Mannequin, settings: MannequinSettings) { + entity.pose = settings.pose + entity.mainHand = settings.mainHand + entity.isImmovable = settings.immovable + val skinParts = entity.skinParts.mutableCopy() + applyLayers(settings.hiddenLayers, skinParts) + entity.setSkinParts(skinParts) + val description = when { + settings.hideDescription -> Component.empty() + settings.description != null -> settings.description + else -> Mannequin.defaultDescription() + } + entity.description = description + val profile = settings.profile?.toResolvable() ?: Mannequin.defaultProfile() + entity.profile = profile + } + + fun spawn(location: Location, settings: MannequinSettings): Mannequin { + val world = location.world ?: throw IllegalStateException("Cannot spawn mannequin without world") + return world.spawn(location, Mannequin::class.java) { mannequin -> + applySettings(mannequin, settings) + } + } + + fun locate(record: MannequinRecord): Mannequin? { + record.entityId?.let { plugin.server.getEntity(it) as? Mannequin }?.let { return it } + val location = record.location?.toLocation(plugin.server) ?: return null + val world = location.world ?: return null + val results = world.getNearbyEntitiesByType(Mannequin::class.java, location, 0.75, 0.75) { true } + return results.minByOrNull { it.location.distanceSquared(location) } + } + + private fun applyLayers(hidden: Set, parts: SkinParts.Mutable) { + parts.setCapeEnabled(!hidden.contains(MannequinHiddenLayer.CAPE)) + parts.setJacketEnabled(!hidden.contains(MannequinHiddenLayer.JACKET)) + parts.setLeftSleeveEnabled(!hidden.contains(MannequinHiddenLayer.LEFT_SLEEVE)) + parts.setRightSleeveEnabled(!hidden.contains(MannequinHiddenLayer.RIGHT_SLEEVE)) + parts.setLeftPantsEnabled(!hidden.contains(MannequinHiddenLayer.LEFT_PANTS)) + parts.setRightPantsEnabled(!hidden.contains(MannequinHiddenLayer.RIGHT_PANTS)) + parts.setHatsEnabled(!hidden.contains(MannequinHiddenLayer.HAT)) + } +} diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinRegistry.kt b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinRegistry.kt new file mode 100644 index 0000000..96b17f7 --- /dev/null +++ b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinRegistry.kt @@ -0,0 +1,121 @@ +package net.hareworks.npc_mannequin.service + +import net.hareworks.npc_mannequin.mannequin.MannequinRecord +import net.hareworks.npc_mannequin.mannequin.MannequinSettings +import net.hareworks.npc_mannequin.mannequin.StoredLocation +import net.hareworks.npc_mannequin.storage.MannequinStorage +import org.bukkit.Location +import org.bukkit.entity.Mannequin +import org.bukkit.plugin.java.JavaPlugin + +class MannequinRegistry( + private val plugin: JavaPlugin, + private val storage: MannequinStorage, + private val controller: MannequinController +) { + private val records: MutableMap = storage.load().toMutableMap() + + fun all(): Collection = records.values.sortedBy { it.id } + + fun find(id: String): MannequinRecord? = records[id] + + fun require(id: String): MannequinRecord = + records[id] ?: error("Mannequin '$id' is not registered.") + + fun register(id: String, entity: Mannequin, overwrite: Boolean = false): MannequinRecord { + if (!overwrite && records.containsKey(id)) { + error("Mannequin '$id' already exists. Use overwrite to replace it.") + } + val snapshot = controller.extractSettings(entity) + val record = MannequinRecord( + id = id, + settings = snapshot, + location = StoredLocation.from(entity.location), + entityId = entity.uniqueId + ) + records[id] = record + persist() + return record + } + + fun create(id: String, location: Location, template: MannequinSettings = MannequinSettings()): MannequinRecord { + if (records.containsKey(id)) { + error("Mannequin '$id' already exists.") + } + val mannequin = controller.spawn(location, template) + val record = MannequinRecord( + id = id, + settings = template, + location = StoredLocation.from(location), + entityId = mannequin.uniqueId + ) + records[id] = record + persist() + return record + } + + fun updateSettings(id: String, updater: (MannequinSettings) -> MannequinSettings): MannequinRecord { + val existing = require(id) + val nextSettings = updater(existing.settings) + val updated = existing.updateSettings(nextSettings) + records[id] = updated + persist() + controller.locate(updated)?.let { controller.applySettings(it, nextSettings) } + return updated + } + + fun apply(id: String, spawnIfMissing: Boolean = true): Mannequin? { + val record = require(id) + var entity = controller.locate(record) + if (entity == null && spawnIfMissing) { + val location = record.location?.toLocation(plugin.server) + ?: error("Mannequin '$id' does not have a saved location to respawn.") + entity = controller.spawn(location, record.settings) + } + entity?.let { + controller.applySettings(it, record.settings) + records[id] = record.updateEntityId(it.uniqueId) + persist() + } + return entity + } + + fun relocate(id: String, location: Location, teleport: Boolean = true): MannequinRecord { + val record = require(id) + val stored = StoredLocation.from(location) + val updated = record.updateLocation(stored) + val entity = if (teleport) controller.locate(updated) else null + val finalRecord = if (entity != null) { + entity.teleport(location) + updated.updateEntityId(entity.uniqueId) + } else { + updated + } + records[id] = finalRecord + persist() + return finalRecord + } + + fun unlink(id: String): MannequinRecord { + val record = require(id) + val updated = record.updateEntityId(null) + records[id] = updated + persist() + return updated + } + + fun remove(id: String, deleteEntity: Boolean) { + val record = require(id) + if (deleteEntity) { + controller.locate(record)?.remove() + } + records.remove(id) + persist() + } + + fun locate(id: String): Mannequin? = controller.locate(require(id)) + + private fun persist() { + storage.save(records.values) + } +} diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/storage/MannequinStorage.kt b/src/main/kotlin/net/hareworks/npc-mannequin/storage/MannequinStorage.kt new file mode 100644 index 0000000..e597f44 --- /dev/null +++ b/src/main/kotlin/net/hareworks/npc-mannequin/storage/MannequinStorage.kt @@ -0,0 +1,139 @@ +package net.hareworks.npc_mannequin.storage + +import net.hareworks.npc_mannequin.mannequin.MannequinHiddenLayer +import net.hareworks.npc_mannequin.mannequin.MannequinRecord +import net.hareworks.npc_mannequin.mannequin.MannequinSettings +import net.hareworks.npc_mannequin.mannequin.StoredLocation +import net.hareworks.npc_mannequin.mannequin.StoredProfile +import net.hareworks.npc_mannequin.mannequin.StoredProfileProperty +import net.hareworks.npc_mannequin.text.TextSerializers +import org.bukkit.configuration.ConfigurationSection +import org.bukkit.configuration.file.YamlConfiguration +import org.bukkit.entity.Pose +import org.bukkit.inventory.MainHand +import org.bukkit.plugin.java.JavaPlugin +import java.io.File +import java.io.IOException +import java.util.UUID + +class MannequinStorage(private val plugin: JavaPlugin) { + private val file: File by lazy { + plugin.dataFolder.mkdirs() + File(plugin.dataFolder, "mannequins.yml") + } + + fun load(): Map { + if (!file.exists()) return emptyMap() + val config = YamlConfiguration() + try { + config.load(file) + } catch (ex: Exception) { + plugin.logger.severe("Failed to load mannequin data: ${ex.message}") + return emptyMap() + } + val records = mutableMapOf() + val section = config.getConfigurationSection("mannequins") ?: return emptyMap() + for (key in section.getKeys(false)) { + val record = section.getConfigurationSection(key)?.let { deserializeRecord(key, it) } ?: continue + records[key] = record + } + return records + } + + fun save(records: Collection) { + val config = YamlConfiguration() + val root = config.createSection("mannequins") + records.forEach { record -> + val section = root.createSection(record.id) + serializeRecord(section, record) + } + try { + config.save(file) + } catch (ex: IOException) { + plugin.logger.severe("Failed to save mannequin data: ${ex.message}") + } + } + + private fun deserializeRecord(id: String, section: ConfigurationSection): MannequinRecord? { + val pose = section.getString("pose")?.let { runCatching { Pose.valueOf(it) }.getOrNull() } ?: Pose.STANDING + val mainHand = section.getString("mainHand") + ?.let { runCatching { MainHand.valueOf(it) }.getOrNull() } ?: MainHand.RIGHT + val immovable = section.getBoolean("immovable", false) + val hideDescription = section.getBoolean("hideDescription", false) + val description = section.getString("description")?.let { TextSerializers.miniMessage(it) } + val hiddenLayers = section.getStringList("hiddenLayers") + .mapNotNull { MannequinHiddenLayer.fromKey(it) } + .toSet() + val profileSection = section.getConfigurationSection("profile") + val profile = profileSection?.let(::deserializeProfile) + val locationSection = section.getConfigurationSection("location") + val entityId = section.getString("entityId")?.let { runCatching { UUID.fromString(it) }.getOrNull() } + val location = locationSection?.let { + val world = it.getString("world") ?: return@let null + val x = it.getDouble("x") + val y = it.getDouble("y") + val z = it.getDouble("z") + val yaw = it.getDouble("yaw").toFloat() + val pitch = it.getDouble("pitch").toFloat() + StoredLocation(world, x, y, z, yaw, pitch) + } + val settings = MannequinSettings( + pose = pose, + mainHand = mainHand, + immovable = immovable, + description = description, + hideDescription = hideDescription, + hiddenLayers = hiddenLayers, + profile = profile + ) + return MannequinRecord(id, settings, location, entityId) + } + + private fun serializeRecord(section: ConfigurationSection, record: MannequinRecord) { + section.set("pose", record.settings.pose.name) + section.set("mainHand", record.settings.mainHand.name) + section.set("immovable", record.settings.immovable) + section.set("hideDescription", record.settings.hideDescription) + section.set("description", TextSerializers.miniMessage(record.settings.description)) + section.set("hiddenLayers", record.settings.hiddenLayers.map { it.key }) + record.settings.profile?.let { profile -> + val profileSection = section.createSection("profile") + profileSection.set("name", profile.name) + profileSection.set("uuid", profile.uuid?.toString()) + val properties = profile.properties.mapIndexed { index, property -> + mapOf( + "name" to property.name, + "value" to property.value, + "signature" to property.signature + ) + } + profileSection.set("properties", properties) + } + record.location?.let { + val locationSection = section.createSection("location") + locationSection.set("world", it.world) + locationSection.set("x", it.x) + locationSection.set("y", it.y) + locationSection.set("z", it.z) + locationSection.set("yaw", it.yaw) + locationSection.set("pitch", it.pitch) + } + section.set("entityId", record.entityId?.toString()) + } + + private fun deserializeProfile(section: ConfigurationSection): StoredProfile? { + val name = section.getString("name") + val uuid = section.getString("uuid")?.let { runCatching { UUID.fromString(it) }.getOrNull() } + val properties = section.getList("properties") + ?.filterIsInstance>() + ?.mapNotNull { entry -> + val propertyName = entry["name"] as? String ?: return@mapNotNull null + val value = entry["value"] as? String ?: return@mapNotNull null + val signature = entry["signature"] as? String + StoredProfileProperty(propertyName, value, signature) + } + ?: emptyList() + if (name == null && uuid == null && properties.isEmpty()) return null + return StoredProfile(name, uuid, properties) + } +} diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/text/TextSerializers.kt b/src/main/kotlin/net/hareworks/npc-mannequin/text/TextSerializers.kt new file mode 100644 index 0000000..36bd7ff --- /dev/null +++ b/src/main/kotlin/net/hareworks/npc-mannequin/text/TextSerializers.kt @@ -0,0 +1,19 @@ +package net.hareworks.npc_mannequin.text + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer + +object TextSerializers { + private val miniMessage = MiniMessage.miniMessage() + private val plain = PlainTextComponentSerializer.plainText() + + fun miniMessage(serialized: String?): Component? = + serialized?.takeIf { it.isNotBlank() }?.let { miniMessage.deserialize(it) } + + fun miniMessage(component: Component?): String? = + component?.let { miniMessage.serialize(it) } + + fun plain(component: Component?): String = + component?.let { plain.serialize(it) }.orEmpty() +}