GhostDisplays/main/kotlin/net/hareworks/ghostdisplays/display/DisplayManager.kt
2025-12-06 04:40:18 +09:00

241 lines
9.3 KiB
Kotlin

package net.hareworks.ghostdisplays.display
import java.time.Instant
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.logging.Logger
import net.hareworks.ghostdisplays.api.DisplayService
import net.hareworks.ghostdisplays.api.InteractionOptions
import net.hareworks.ghostdisplays.api.audience.AudiencePredicates
import net.hareworks.ghostdisplays.display.DisplayManager.DisplayOperationException
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.block.data.BlockData
import org.bukkit.entity.BlockDisplay
import org.bukkit.entity.Display
import org.bukkit.entity.ItemDisplay
import org.bukkit.entity.Player
import org.bukkit.entity.TextDisplay
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
class DisplayManager(
private val plugin: JavaPlugin,
private val service: DisplayService,
private val miniMessage: MiniMessage
) {
private val displays = ConcurrentHashMap<String, ManagedDisplay<*>>()
private val idPattern = Regex("^[a-z0-9_\\-]{1,32}$")
private val logger: Logger = plugin.logger
private val plainSerializer = PlainTextComponentSerializer.plainText()
fun createTextDisplay(id: String, location: Location, initialContent: String, creator: Player?): ManagedDisplay.Text {
val normalized = normalizeId(id)
ensureIdAvailable(normalized)
val safeLocation = location.clone()
val controller = service.createTextDisplay(safeLocation, INTERACTION_DEFAULT)
val component = parseMiniMessage(initialContent.ifBlank { "<gray>${normalized}" })
controller.applyEntityUpdate { textDisplay ->
textDisplay.text(component)
}
val managed = ManagedDisplay.Text(
id = normalized,
controller = controller,
location = safeLocation,
createdAt = Instant.now(),
createdBy = creator?.uniqueId,
rawContent = initialContent,
component = component
)
displays[normalized] = managed
creator?.let { controller.show(it) }
return managed
}
fun createBlockDisplay(id: String, location: Location, blockData: BlockData, creator: Player?): ManagedDisplay.Block {
val normalized = normalizeId(id)
ensureIdAvailable(normalized)
val safeLocation = location.clone()
val controller = service.createBlockDisplay(safeLocation, INTERACTION_DEFAULT)
controller.applyEntityUpdate { it.block = blockData.clone() }
val managed = ManagedDisplay.Block(
id = normalized,
controller = controller,
location = safeLocation,
createdAt = Instant.now(),
createdBy = creator?.uniqueId,
blockData = blockData.clone()
)
displays[normalized] = managed
creator?.let { controller.show(it) }
return managed
}
fun createItemDisplay(id: String, location: Location, itemStack: ItemStack, creator: Player?): ManagedDisplay.Item {
val normalized = normalizeId(id)
ensureIdAvailable(normalized)
val safeLocation = location.clone()
val controller = service.createItemDisplay(safeLocation, itemStack.clone(), INTERACTION_DEFAULT)
controller.applyEntityUpdate { it.itemStack = itemStack.clone() }
val managed = ManagedDisplay.Item(
id = normalized,
controller = controller,
location = safeLocation,
createdAt = Instant.now(),
createdBy = creator?.uniqueId,
itemStack = itemStack.clone()
)
displays[normalized] = managed
creator?.let { controller.show(it) }
return managed
}
fun listDisplays(): List<ManagedDisplay<*>> = displays.values.sortedBy { it.id }
fun findDisplay(id: String): ManagedDisplay<*>? = displays[normalizeId(id)]
fun updateText(id: String, content: String): Component {
val display = requireText(id)
val component = parseMiniMessage(content)
display.controller.applyEntityUpdate { it.text(component) }
display.rawContent = content
display.component = component
return component
}
fun updateBlock(id: String, blockData: BlockData) {
val display = requireBlock(id)
display.controller.applyEntityUpdate { it.block = blockData.clone() }
display.blockData = blockData.clone()
}
fun updateItem(id: String, itemStack: ItemStack) {
val display = requireItem(id)
val clone = itemStack.clone()
display.controller.applyEntityUpdate { it.itemStack = clone }
display.itemStack = clone
}
fun delete(id: String) {
val normalized = normalizeId(id)
val display = displays.remove(normalized) ?: throw DisplayOperationException("Display '$id' does not exist.")
display.clearAudiences()
display.controller.destroy()
}
fun showToPlayers(id: String, players: Collection<Player>) {
val display = requireDisplay(id)
players.forEach { display.controller.show(it) }
}
fun hideFromPlayers(id: String, players: Collection<Player>) {
val display = requireDisplay(id)
players.forEach { display.controller.hide(it) }
}
fun clearViewers(id: String) {
val display = requireDisplay(id)
val viewers = display.controller.viewerIds().mapNotNull { Bukkit.getPlayer(it) }
viewers.forEach { display.controller.hide(it) }
}
fun addPermissionAudience(id: String, permission: String) {
val display = requireDisplay(id)
val key = "perm:${permission.lowercase()}"
if (display.removeAudienceBinding(key)) {
logger.info("Replacing permission audience '$permission' for $id")
}
val registration = display.controller.addAudience(AudiencePredicates.permission(permission))
display.registerAudienceBinding(
AudienceBinding(
key = key,
description = "permission:$permission",
registration = registration
)
)
}
fun removePermissionAudience(id: String, permission: String): Boolean {
val display = requireDisplay(id)
val key = "perm:${permission.lowercase()}"
return display.removeAudienceBinding(key)
}
fun setNearAudience(id: String, radius: Double) {
require(radius > 0) { "Radius must be positive." }
val display = requireDisplay(id)
val key = "near"
display.removeAudienceBinding(key)
val registration = display.controller.addAudience(AudiencePredicates.near(display.location, radius))
display.registerAudienceBinding(
AudienceBinding(
key = key,
description = "radius:${String.format("%.2f", radius)}",
registration = registration
)
)
}
fun clearAudiences(id: String) {
val display = requireDisplay(id)
display.clearAudiences()
}
fun destroyAll() {
displays.values.forEach {
runCatching { it.clearAudiences() }
runCatching { it.controller.destroy() }
}
displays.clear()
}
fun describeContent(display: ManagedDisplay<*>): String =
when (display) {
is ManagedDisplay.Text -> plainSerializer.serialize(display.component)
is ManagedDisplay.Block -> display.blockData.asString
is ManagedDisplay.Item -> display.itemStack.type.name.lowercase()
}
private fun requireDisplay(id: String): ManagedDisplay<*> =
displays[normalizeId(id)] ?: throw DisplayOperationException("Display '$id' does not exist.")
private fun requireText(id: String): ManagedDisplay.Text =
findDisplay(id) as? ManagedDisplay.Text
?: throw DisplayOperationException("Display '$id' is not a text display.")
private fun requireBlock(id: String): ManagedDisplay.Block =
findDisplay(id) as? ManagedDisplay.Block
?: throw DisplayOperationException("Display '$id' is not a block display.")
private fun requireItem(id: String): ManagedDisplay.Item =
findDisplay(id) as? ManagedDisplay.Item
?: throw DisplayOperationException("Display '$id' is not an item display.")
private fun normalizeId(id: String): String =
id.trim().lowercase()
private fun ensureIdAvailable(id: String) {
if (!idPattern.matches(id)) {
throw DisplayOperationException("ID must match ${idPattern.pattern} and be 1-32 characters.")
}
if (displays.containsKey(id)) {
throw DisplayOperationException("Display '$id' already exists.")
}
}
private fun parseMiniMessage(raw: String): Component =
runCatching { miniMessage.deserialize(if (raw.isBlank()) "<gray>empty" else raw) }
.getOrElse { ex ->
throw DisplayOperationException("MiniMessage parse error: ${ex.message ?: ex.javaClass.simpleName}")
}
companion object {
private val INTERACTION_DEFAULT = InteractionOptions.enabled(width = 0.8, height = 0.8)
}
class DisplayOperationException(message: String) : RuntimeException(message)
}