This commit is contained in:
2025-12-06 04:40:18 +09:00
commit 4a302f5f6f
27 changed files with 1894 additions and 0 deletions
@@ -0,0 +1,66 @@
package net.hareworks.ghostdisplays
import net.hareworks.ghostdisplays.api.DisplayService
import net.hareworks.ghostdisplays.command.CommandRegistrar
import net.hareworks.ghostdisplays.display.DisplayManager
import net.hareworks.ghostdisplays.display.EditSessionManager
import net.hareworks.ghostdisplays.internal.DefaultDisplayService
import net.hareworks.ghostdisplays.internal.controller.DisplayRegistry
import net.hareworks.kommand_lib.KommandLib
import net.hareworks.permits_lib.PermitsLib
import net.hareworks.permits_lib.bukkit.MutationSession
import net.kyori.adventure.text.minimessage.MiniMessage
import org.bukkit.plugin.ServicePriority
import org.bukkit.plugin.java.JavaPlugin
class GhostDisplaysPlugin : JavaPlugin() {
private lateinit var displayRegistry: DisplayRegistry
private lateinit var serviceImpl: DisplayService
private lateinit var displayManager: DisplayManager
private lateinit var editSessions: EditSessionManager
private lateinit var miniMessage: MiniMessage
private var permissionSession: MutationSession? = null
private var kommand: KommandLib? = null
override fun onEnable() {
miniMessage = MiniMessage.miniMessage()
displayRegistry = DisplayRegistry().also {
server.pluginManager.registerEvents(it, this)
}
serviceImpl = DefaultDisplayService(this, displayRegistry)
displayManager = DisplayManager(this, serviceImpl, miniMessage)
editSessions = EditSessionManager(this, displayManager).also {
server.pluginManager.registerEvents(it, this)
}
server.servicesManager.register(DisplayService::class.java, serviceImpl, this, ServicePriority.Normal)
permissionSession = PermitsLib.session(this)
kommand = CommandRegistrar.register(this, displayManager, editSessions, permissionSession!!)
instance = this
logger.info("GhostDisplays ready: ${displayRegistry.controllerCount()} controllers active.")
}
override fun onDisable() {
kommand?.unregister()
kommand = null
runCatching { editSessions.shutdown() }
runCatching { displayManager.destroyAll() }
runCatching { serviceImpl.destroyAll() }
permissionSession = null
server.servicesManager.unregister(DisplayService::class.java, serviceImpl)
displayRegistry.shutdown()
instance = null
}
companion object {
@Volatile
private var instance: GhostDisplaysPlugin? = null
fun service(): DisplayService =
instance?.serviceImpl ?: throw IllegalStateException("GhostDisplays plugin not enabled")
fun manager(): DisplayManager =
instance?.displayManager ?: throw IllegalStateException("GhostDisplays plugin not enabled")
}
}
@@ -0,0 +1,33 @@
package net.hareworks.ghostdisplays.api
import net.hareworks.ghostdisplays.api.audience.AudiencePredicate
import net.hareworks.ghostdisplays.api.click.ClickPriority
import net.hareworks.ghostdisplays.api.click.DisplayClickHandler
import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import org.bukkit.entity.Display
import org.bukkit.entity.Interaction
import org.bukkit.entity.Player
import java.util.UUID
interface DisplayController<T : Display> {
val display: T
val interaction: Interaction?
fun show(player: Player)
fun hide(player: Player)
fun isViewing(playerId: UUID): Boolean
fun viewerIds(): Set<UUID>
fun applyEntityUpdate(mutator: (T) -> Unit)
fun addAudience(predicate: AudiencePredicate): HandlerRegistration
fun refreshAudience(target: Player? = null)
fun destroy()
fun onClick(priority: ClickPriority = ClickPriority.NORMAL, handler: DisplayClickHandler): HandlerRegistration
}
@@ -0,0 +1,30 @@
package net.hareworks.ghostdisplays.api
import org.bukkit.Location
import org.bukkit.entity.BlockDisplay
import org.bukkit.entity.ItemDisplay
import org.bukkit.entity.TextDisplay
import org.bukkit.inventory.ItemStack
interface DisplayService {
fun createTextDisplay(
location: Location,
interaction: InteractionOptions = InteractionOptions.Disabled,
builder: TextDisplay.() -> Unit = {}
): DisplayController<TextDisplay>
fun createBlockDisplay(
location: Location,
interaction: InteractionOptions = InteractionOptions.Disabled,
builder: BlockDisplay.() -> Unit = {}
): DisplayController<BlockDisplay>
fun createItemDisplay(
location: Location,
itemStack: ItemStack,
interaction: InteractionOptions = InteractionOptions.Disabled,
builder: ItemDisplay.() -> Unit = {}
): DisplayController<ItemDisplay>
fun destroyAll()
}
@@ -0,0 +1,28 @@
package net.hareworks.ghostdisplays.api
import kotlin.math.max
/**
* 設置されたDisplay上にクリック判定用のInteractionエンティティを重ねる際のオプション。
*/
data class InteractionOptions(
val enabled: Boolean = false,
val width: Double = 0.8,
val height: Double = 0.8,
val responsive: Boolean = true
) {
init {
require(width > 0.0) { "width must be positive" }
require(height > 0.0) { "height must be positive" }
}
fun effectiveWidth(): Double = max(width, 0.1)
fun effectiveHeight(): Double = max(height, 0.1)
companion object {
val Disabled = InteractionOptions()
fun enabled(width: Double = 0.8, height: Double = 0.8, responsive: Boolean = true): InteractionOptions =
InteractionOptions(true, width, height, responsive)
}
}
@@ -0,0 +1,10 @@
package net.hareworks.ghostdisplays.api.audience
import org.bukkit.entity.Player
/**
* Displayを視認させる対象かどうかを判定する述語。
*/
fun interface AudiencePredicate {
fun test(player: Player): Boolean
}
@@ -0,0 +1,30 @@
package net.hareworks.ghostdisplays.api.audience
import org.bukkit.Location
import org.bukkit.entity.Player
import java.util.UUID
object AudiencePredicates {
fun anyone(): AudiencePredicate = AudiencePredicate { true }
fun permission(node: String): AudiencePredicate = AudiencePredicate { player ->
player.hasPermission(node)
}
fun world(worldName: String): AudiencePredicate = AudiencePredicate { player ->
player.world.name.equals(worldName, ignoreCase = true)
}
fun uuid(target: UUID): AudiencePredicate = AudiencePredicate { player ->
player.uniqueId == target
}
fun near(location: Location, radius: Double): AudiencePredicate {
val radiusSq = radius * radius
val worldName = location.world?.name
require(worldName != null) { "Location must have a world" }
return AudiencePredicate { player ->
player.world.name == worldName && player.location.distanceSquared(location) <= radiusSq
}
}
}
@@ -0,0 +1,35 @@
package net.hareworks.ghostdisplays.api.click
import net.hareworks.ghostdisplays.api.DisplayController
import org.bukkit.entity.Entity
import org.bukkit.entity.Player
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.inventory.EquipmentSlot
enum class ClickPriority {
HIGH,
NORMAL,
LOW
}
enum class ClickSurface {
DISPLAY,
INTERACTION
}
data class DisplayClickContext(
val player: Player,
val hand: EquipmentSlot?,
val clickedEntity: Entity,
val surface: ClickSurface,
val controller: DisplayController<*>,
val event: PlayerInteractEntityEvent
)
fun interface DisplayClickHandler {
fun handle(context: DisplayClickContext)
}
fun interface HandlerRegistration {
fun unregister()
}
@@ -0,0 +1,417 @@
package net.hareworks.ghostdisplays.command
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import net.hareworks.ghostdisplays.display.DisplayManager
import net.hareworks.ghostdisplays.display.DisplayManager.DisplayOperationException
import net.hareworks.ghostdisplays.display.DisplayKind
import net.hareworks.ghostdisplays.display.EditSessionManager
import net.hareworks.kommand_lib.KommandLib
import net.hareworks.kommand_lib.kommand
import net.hareworks.permits_lib.bukkit.MutationSession
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.block.data.BlockData
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.bukkit.permissions.PermissionDefault
import org.bukkit.plugin.java.JavaPlugin
object CommandRegistrar {
fun register(
plugin: JavaPlugin,
manager: DisplayManager,
editSessions: EditSessionManager,
permissionSession: MutationSession
): KommandLib =
kommand(plugin) {
permissions {
namespace = "ghostdisplays"
defaultValue = PermissionDefault.OP
wildcard = true
session(permissionSession)
}
command("ghostdisplay", listOf("gdisplay", "gdisp")) {
description = "Manage GhostDisplays entities"
permission = "ghostdisplays.command"
executes { sender.showUsage() }
literal("help") {
executes { sender.showUsage() }
}
literal("create") {
literal("text") {
string("id") {
executes {
val player = sender.requirePlayer() ?: return@executes
val id = argument<String>("id")
val location = player.anchorLocation()
try {
val display = manager.createTextDisplay(id, location, "", player)
editSessions.begin(player, display.id)
sender.success("Text display '${display.id}' created at ${describe(location)}. Type text in chat (or 'cancel') to set content.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to create text display.")
}
}
}
}
literal("block") {
string("id") {
string("state") {
executes {
val player = sender.requirePlayer() ?: return@executes
val id = argument<String>("id")
val state = argument<String>("state")
try {
val blockData = parseBlockData(state)
val display = manager.createBlockDisplay(id, player.anchorLocation(), blockData, player)
sender.success("Block display '${display.id}' created with $state at ${describe(display.location)}.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to create block display.")
} catch (ex: IllegalArgumentException) {
sender.failure("Invalid block data '$state': ${ex.message}")
}
}
}
}
}
literal("item") {
string("id") {
string("material") {
executes {
val player = sender.requirePlayer() ?: return@executes
val id = argument<String>("id")
val materialToken = argument<String>("material")
val material = Material.matchMaterial(materialToken.uppercase(Locale.ROOT))
?: return@executes sender.failure("Unknown material '$materialToken'.")
if (!material.isItem) {
return@executes sender.failure("$materialToken cannot be used as an item display.")
}
try {
val item = ItemStack(material)
val display = manager.createItemDisplay(id, player.anchorLocation(), item, player)
sender.success("Item display '${display.id}' created with ${material.name.lowercase()} at ${describe(display.location)}.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to create item display.")
}
}
}
}
}
}
literal("delete") {
string("id") {
executes {
val id = argument<String>("id")
try {
manager.delete(id)
sender.success("Display '$id' removed.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to delete '$id'.")
}
}
}
}
literal("list") {
executes {
val entries = manager.listDisplays()
if (entries.isEmpty()) {
sender.info("No displays registered.")
return@executes
}
sender.info("Displays (${entries.size}):")
entries.forEach {
sender.info(" - ${it.id} [${it.kind}] @ ${describe(it.location)} viewers=${it.controller.viewerIds().size}")
}
}
}
literal("info") {
string("id") {
executes {
val id = argument<String>("id")
val display = manager.findDisplay(id)
if (display == null) {
sender.failure("Display '$id' not found.")
return@executes
}
sender.info("Display '${display.id}' (${display.kind})")
sender.info(" Location: ${describe(display.location)}")
sender.info(" Created: ${DATE_FORMAT.format(display.createdAt)} by ${display.createdBy?.let { Bukkit.getOfflinePlayer(it).name ?: it } ?: "unknown"}")
val viewers = display.controller.viewerIds()
sender.info(" Viewers (${viewers.size}): ${resolveNames(viewers).ifEmpty { "none" }}")
val audiences = display.audienceBindings()
sender.info(" Audiences (${audiences.size}): ${if (audiences.isEmpty()) "none" else audiences.joinToString { it.description }}")
sender.info(" Content: ${manager.describeContent(display)}")
}
}
}
literal("viewer") {
literal("add") {
string("id") {
players("targets") {
executes {
val id = argument<String>("id")
val targets = argument<List<Player>>("targets")
try {
manager.showToPlayers(id, targets)
sender.success("Showing '$id' to ${targets.size} player(s).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update viewers.")
}
}
}
}
}
literal("remove") {
string("id") {
players("targets") {
executes {
val id = argument<String>("id")
val targets = argument<List<Player>>("targets")
try {
manager.hideFromPlayers(id, targets)
sender.success("Hid '$id' from ${targets.size} player(s).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update viewers.")
}
}
}
}
}
literal("clear") {
string("id") {
executes {
val id = argument<String>("id")
try {
manager.clearViewers(id)
sender.success("Cleared active viewers for '$id'.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to clear viewers.")
}
}
}
}
}
literal("text") {
literal("edit") {
string("id") {
executes {
val player = sender.requirePlayer() ?: return@executes
val id = argument<String>("id")
val display = manager.findDisplay(id)
if (display == null || display.kind != DisplayKind.TEXT) {
sender.failure("Text display '$id' not found.")
return@executes
}
editSessions.begin(player, display.id)
sender.info("Enter new MiniMessage text in chat for '${display.id}'. Type 'cancel' to abort.")
}
}
}
literal("set") {
string("id") {
string("content") {
executes {
val id = argument<String>("id")
val token = argument<String>("content")
val raw = token.replace('_', ' ').replace("\\n", "\n")
try {
manager.updateText(id, raw)
sender.success("Updated text for '$id'. Use /ghostdisplay text edit $id for multi-line content.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update text.")
}
}
}
}
}
literal("cancel") {
executes {
val player = sender.requirePlayer() ?: return@executes
if (editSessions.cancel(player)) {
sender.success("Editing session cancelled.")
} else {
sender.failure("You are not editing any display.")
}
}
}
}
literal("block") {
literal("set") {
string("id") {
string("state") {
executes {
val id = argument<String>("id")
val state = argument<String>("state")
try {
val blockData = parseBlockData(state)
manager.updateBlock(id, blockData)
sender.success("Block data for '$id' updated to $state.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update block display.")
} catch (ex: IllegalArgumentException) {
sender.failure("Invalid block data '$state': ${ex.message}")
}
}
}
}
}
}
literal("item") {
literal("set") {
string("id") {
string("material") {
executes {
val id = argument<String>("id")
val materialToken = argument<String>("material")
val material = Material.matchMaterial(materialToken.uppercase(Locale.ROOT))
?: return@executes sender.failure("Unknown material '$materialToken'.")
if (!material.isItem) {
return@executes sender.failure("$materialToken cannot be used as an item display.")
}
try {
manager.updateItem(id, ItemStack(material))
sender.success("Item for '$id' set to ${material.name.lowercase()}.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update item display.")
}
}
}
}
}
}
literal("audience") {
literal("permission") {
literal("add") {
string("id") {
string("permission") {
executes {
val id = argument<String>("id")
val perm = argument<String>("permission")
try {
manager.addPermissionAudience(id, perm)
sender.success("Permission audience '$perm' added to '$id'.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to add permission audience.")
}
}
}
}
}
literal("remove") {
string("id") {
string("permission") {
executes {
val id = argument<String>("id")
val perm = argument<String>("permission")
val removed = runCatching { manager.removePermissionAudience(id, perm) }
.onFailure { sender.failure(it.message ?: "Failed to remove permission audience.") }
.getOrNull()
if (removed == true) {
sender.success("Permission audience '$perm' removed from '$id'.")
} else if (removed == false) {
sender.failure("Permission audience '$perm' not found for '$id'.")
}
}
}
}
}
}
literal("near") {
literal("set") {
string("id") {
float("radius", min = 1.0) {
executes {
val id = argument<String>("id")
val radius = argument<Double>("radius")
try {
manager.setNearAudience(id, radius)
sender.success("Radius audience for '$id' set to ${String.format("%.1f", radius)} block(s).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update radius audience.")
}
}
}
}
}
}
literal("clear") {
string("id") {
executes {
val id = argument<String>("id")
try {
manager.clearAudiences(id)
sender.success("Audiences cleared for '$id'.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to clear audiences.")
}
}
}
}
}
}
}
}
private fun CommandSender.showUsage() {
info("GhostDisplays commands:")
info(" /ghostdisplay create <text|block|item> ...")
info(" /ghostdisplay text edit <id> - edit text via chat")
info(" /ghostdisplay viewer <add|remove|clear> ...")
info(" /ghostdisplay audience <permission|near|clear> ...")
info(" /ghostdisplay list | info <id> | delete <id>")
}
private fun Player.anchorLocation(): Location =
eyeLocation.clone().add(direction.normalize().multiply(1.5))
private fun parseBlockData(state: String): BlockData =
Bukkit.createBlockData(state)
private fun describe(location: Location): String =
"${location.world?.name ?: "unknown"} @ ${location.blockX},${location.blockY},${location.blockZ}"
private fun CommandSender.requirePlayer(): Player? {
val player = this as? Player
if (player == null) {
failure("This command can only be used by players.")
}
return player
}
private fun CommandSender.info(message: String) {
sendMessage("§7$message")
}
private fun CommandSender.success(message: String) {
sendMessage("§a$message")
}
private fun CommandSender.failure(message: String) {
sendMessage("§c$message")
}
private fun resolveNames(ids: Collection<UUID>): String {
if (ids.isEmpty()) return ""
return ids.joinToString { Bukkit.getOfflinePlayer(it).name ?: it.toString() }
}
private val DATE_FORMAT: DateTimeFormatter =
DateTimeFormatter.ISO_INSTANT
@@ -0,0 +1,240 @@
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)
}
@@ -0,0 +1,56 @@
package net.hareworks.ghostdisplays.display
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import net.hareworks.ghostdisplays.display.DisplayManager.DisplayOperationException
import org.bukkit.Bukkit
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.player.AsyncPlayerChatEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.plugin.java.JavaPlugin
class EditSessionManager(
private val plugin: JavaPlugin,
private val manager: DisplayManager
) : Listener {
private val sessions = ConcurrentHashMap<UUID, String>()
fun begin(player: Player, displayId: String) {
sessions[player.uniqueId] = displayId
}
fun cancel(player: Player): Boolean = sessions.remove(player.uniqueId) != null
fun shutdown() {
sessions.clear()
}
@EventHandler(ignoreCancelled = true)
fun onPlayerChat(event: AsyncPlayerChatEvent) {
val displayId = sessions[event.player.uniqueId] ?: return
event.isCancelled = true
val message = event.message
if (message.equals("cancel", ignoreCase = true)) {
sessions.remove(event.player.uniqueId)
event.player.sendMessage("GhostDisplays: editing for '$displayId' cancelled.")
return
}
Bukkit.getScheduler().runTask(plugin) {
try {
manager.updateText(displayId, message)
event.player.sendMessage("GhostDisplays: updated text for '$displayId'.")
} catch (ex: DisplayOperationException) {
event.player.sendMessage("GhostDisplays: ${ex.message}")
} finally {
sessions.remove(event.player.uniqueId)
}
}
}
@EventHandler
fun onPlayerQuit(event: PlayerQuitEvent) {
sessions.remove(event.player.uniqueId)
}
}
@@ -0,0 +1,89 @@
package net.hareworks.ghostdisplays.display
import java.time.Instant
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import net.hareworks.ghostdisplays.api.DisplayController
import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import net.kyori.adventure.text.Component
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.TextDisplay
import org.bukkit.inventory.ItemStack
enum class DisplayKind {
TEXT,
BLOCK,
ITEM
}
data class AudienceBinding(
val key: String,
val description: String,
private val registration: HandlerRegistration
) {
fun unregister() {
registration.unregister()
}
}
sealed class ManagedDisplay<T : Display>(
val id: String,
val kind: DisplayKind,
val controller: DisplayController<T>,
location: Location,
val createdAt: Instant,
val createdBy: UUID?
) {
val location: Location = location.clone()
private val audienceBindings = ConcurrentHashMap<String, AudienceBinding>()
fun audienceBindings(): Collection<AudienceBinding> = audienceBindings.values
fun registerAudienceBinding(binding: AudienceBinding) {
audienceBindings.put(binding.key, binding)?.unregister()
}
fun removeAudienceBinding(key: String): Boolean {
return audienceBindings.remove(key)?.let {
it.unregister()
true
} ?: false
}
fun clearAudiences() {
audienceBindings.values.forEach { it.unregister() }
audienceBindings.clear()
}
class Text(
id: String,
controller: DisplayController<TextDisplay>,
location: Location,
createdAt: Instant,
createdBy: UUID?,
var rawContent: String,
var component: Component
) : ManagedDisplay<TextDisplay>(id, DisplayKind.TEXT, controller, location, createdAt, createdBy)
class Block(
id: String,
controller: DisplayController<BlockDisplay>,
location: Location,
createdAt: Instant,
createdBy: UUID?,
var blockData: BlockData
) : ManagedDisplay<BlockDisplay>(id, DisplayKind.BLOCK, controller, location, createdAt, createdBy)
class Item(
id: String,
controller: DisplayController<ItemDisplay>,
location: Location,
createdAt: Instant,
createdBy: UUID?,
var itemStack: ItemStack
) : ManagedDisplay<ItemDisplay>(id, DisplayKind.ITEM, controller, location, createdAt, createdBy)
}
@@ -0,0 +1,101 @@
package net.hareworks.ghostdisplays.internal
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CopyOnWriteArraySet
import net.hareworks.ghostdisplays.api.DisplayController
import net.hareworks.ghostdisplays.api.DisplayService
import net.hareworks.ghostdisplays.api.InteractionOptions
import net.hareworks.ghostdisplays.internal.controller.BaseDisplayController
import net.hareworks.ghostdisplays.internal.controller.DisplayRegistry
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.entity.BlockDisplay
import org.bukkit.entity.Display
import org.bukkit.entity.Entity
import org.bukkit.entity.Interaction
import org.bukkit.entity.ItemDisplay
import org.bukkit.entity.TextDisplay
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
internal class DefaultDisplayService(
private val plugin: JavaPlugin,
private val registry: DisplayRegistry
) : DisplayService {
private val controllers = CopyOnWriteArraySet<BaseDisplayController<out Display>>()
override fun createTextDisplay(
location: Location,
interaction: InteractionOptions,
builder: TextDisplay.() -> Unit
): DisplayController<TextDisplay> = spawnDisplay(location, TextDisplay::class.java, interaction) {
builder(it)
}
override fun createBlockDisplay(
location: Location,
interaction: InteractionOptions,
builder: BlockDisplay.() -> Unit
): DisplayController<BlockDisplay> = spawnDisplay(location, BlockDisplay::class.java, interaction) {
builder(it)
}
override fun createItemDisplay(
location: Location,
itemStack: ItemStack,
interaction: InteractionOptions,
builder: ItemDisplay.() -> Unit
): DisplayController<ItemDisplay> = spawnDisplay(location, ItemDisplay::class.java, interaction) {
it.itemStack = itemStack.clone()
builder(it)
}
override fun destroyAll() {
controllers.forEach { it.destroy() }
controllers.clear()
}
private fun <T : Display> spawnDisplay(
location: Location,
type: Class<T>,
interactionOptions: InteractionOptions,
afterSpawn: (T) -> Unit
): DisplayController<T> {
val entity = callSync {
val world = location.world ?: throw IllegalArgumentException("Location must have a world")
world.spawn(location, type) { spawned ->
spawned.setPersistent(false)
spawned.setVisibleByDefault(false)
}
}
callSync { afterSpawn(entity) }
val interaction = if (interactionOptions.enabled) spawnInteraction(location, interactionOptions) else null
val controller = BaseDisplayController(plugin, entity, interaction, registry)
controllers += controller
registry.register(controller)
return controller
}
private fun spawnInteraction(location: Location, options: InteractionOptions): Interaction =
callSync {
val world = location.world ?: throw IllegalArgumentException("Location must have a world")
world.spawn(location, Interaction::class.java) { interaction ->
interaction.setPersistent(false)
interaction.setInvisible(true)
interaction.setVisibleByDefault(false)
interaction.setResponsive(options.responsive)
interaction.setInteractionWidth(options.effectiveWidth().toFloat())
interaction.setInteractionHeight(options.effectiveHeight().toFloat())
}
}
private fun <T> callSync(action: () -> T): T {
return if (Bukkit.isPrimaryThread()) {
action()
} else {
val future = CompletableFuture<T>()
Bukkit.getScheduler().runTask(plugin) { future.complete(action()) }
future.join()
}
}
}
@@ -0,0 +1,199 @@
package net.hareworks.ghostdisplays.internal.controller
import java.util.HashSet
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import net.hareworks.ghostdisplays.api.DisplayController
import net.hareworks.ghostdisplays.api.audience.AudiencePredicate
import net.hareworks.ghostdisplays.api.click.ClickPriority
import net.hareworks.ghostdisplays.api.click.ClickSurface
import net.hareworks.ghostdisplays.api.click.DisplayClickContext
import net.hareworks.ghostdisplays.api.click.DisplayClickHandler
import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import org.bukkit.Bukkit
import org.bukkit.entity.Display
import org.bukkit.entity.Entity
import org.bukkit.entity.Interaction
import org.bukkit.entity.Player
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.plugin.java.JavaPlugin
internal class BaseDisplayController<T : Display>(
private val plugin: JavaPlugin,
override val display: T,
override val interaction: Interaction?,
private val registry: DisplayRegistry
) : DisplayController<T> {
private val destroyed = AtomicBoolean(false)
private val viewerCounts = ConcurrentHashMap<UUID, Int>()
private val handlers = CopyOnWriteArrayList<HandlerEntry>()
private val audiences = CopyOnWriteArrayList<AudienceBindingImpl>()
override fun show(player: Player) {
runSync {
val uuid = player.uniqueId
val newCount = viewerCounts.compute(uuid) { _, count -> (count ?: 0) + 1 } ?: 1
if (newCount == 1) {
player.showEntity(plugin, display)
interaction?.let { player.showEntity(plugin, it) }
}
}
}
override fun hide(player: Player) {
runSync {
val uuid = player.uniqueId
val current = viewerCounts[uuid] ?: return@runSync
if (current <= 1) {
viewerCounts.remove(uuid)
player.hideEntity(plugin, display)
interaction?.let { player.hideEntity(plugin, it) }
} else {
viewerCounts[uuid] = current - 1
}
}
}
override fun isViewing(playerId: UUID): Boolean = viewerCounts.containsKey(playerId)
override fun viewerIds(): Set<UUID> = HashSet(viewerCounts.keys)
override fun applyEntityUpdate(mutator: (T) -> Unit) {
callSync {
mutator(display)
display.updateDisplay(false)
}
}
override fun destroy() {
if (!destroyed.compareAndSet(false, true)) return
registry.unregister(this)
val viewers = viewerIds().mapNotNull { Bukkit.getPlayer(it) }
runSync {
viewers.forEach {
it.hideEntity(plugin, display)
interaction?.let { interactionEntity -> it.hideEntity(plugin, interactionEntity) }
}
display.remove()
interaction?.remove()
viewerCounts.clear()
}
handlers.clear()
audiences.forEach { it.clear() }
audiences.clear()
}
override fun onClick(priority: ClickPriority, handler: DisplayClickHandler): HandlerRegistration {
val entry = HandlerEntry(priority, handler)
handlers += entry
return HandlerRegistration {
handlers.remove(entry)
}
}
override fun addAudience(predicate: AudiencePredicate): HandlerRegistration {
val binding = AudienceBindingImpl(predicate)
audiences += binding
refreshAudienceInternal(binding = binding)
return HandlerRegistration { binding.unregister() }
}
private fun refreshAudienceInternal(target: Player? = null, binding: AudienceBindingImpl? = null) {
runSync {
val players = target?.let { listOf(it) } ?: Bukkit.getOnlinePlayers()
val targets = binding?.let { listOf(it) } ?: audiences
if (players.isEmpty() || targets.isEmpty()) return@runSync
players.forEach { player ->
targets.forEach { it.evaluate(player) }
}
}
}
override fun refreshAudience(target: Player?) {
refreshAudienceInternal(target, null)
}
internal fun handleClick(event: PlayerInteractEntityEvent, clicked: Entity, surface: ClickSurface) {
val sortedHandlers = handlers.sortedBy { it.priority.ordinal }
if (sortedHandlers.isEmpty()) return
val context = DisplayClickContext(
player = event.player,
hand = event.hand,
clickedEntity = clicked,
surface = surface,
controller = this,
event = event
)
sortedHandlers.forEach { entry ->
entry.handler.handle(context)
if (event.isCancelled) return
}
}
internal fun handlePlayerQuit(player: Player) {
val uuid = player.uniqueId
viewerCounts.remove(uuid)
audiences.forEach { it.forget(uuid) }
}
private fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().runTask(plugin, action)
}
}
private fun <R> callSync(action: () -> R): R {
return if (Bukkit.isPrimaryThread()) {
action()
} else {
val future = CompletableFuture<R>()
Bukkit.getScheduler().runTask(plugin) { future.complete(action()) }
future.join()
}
}
private inner class AudienceBindingImpl(
private val predicate: AudiencePredicate
) {
private val activeViewers = ConcurrentHashMap.newKeySet<UUID>()
fun evaluate(player: Player) {
val uuid = player.uniqueId
val matches = runCatching { predicate.test(player) }.getOrDefault(false)
if (matches) {
if (activeViewers.add(uuid)) {
show(player)
}
} else {
if (activeViewers.remove(uuid)) {
hide(player)
}
}
}
fun forget(playerId: UUID) {
activeViewers.remove(playerId)
}
fun unregister() {
audiences.remove(this)
val targets = activeViewers.toList()
targets.mapNotNull { Bukkit.getPlayer(it) }.forEach { hide(it) }
activeViewers.clear()
}
fun clear() {
activeViewers.clear()
}
}
private data class HandlerEntry(
val priority: ClickPriority,
val handler: DisplayClickHandler
)
}
@@ -0,0 +1,84 @@
package net.hareworks.ghostdisplays.internal.controller
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import net.hareworks.ghostdisplays.api.click.ClickSurface
import org.bukkit.entity.Display
import org.bukkit.entity.Entity
import org.bukkit.entity.Interaction
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerChangedWorldEvent
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.event.player.PlayerRespawnEvent
internal class DisplayRegistry : Listener {
private val displayControllers = ConcurrentHashMap<UUID, BaseDisplayController<out Display>>()
private val interactionControllers = ConcurrentHashMap<UUID, BaseDisplayController<out Display>>()
fun register(controller: BaseDisplayController<out Display>) {
displayControllers[controller.display.uniqueId] = controller
controller.interaction?.let {
interactionControllers[it.uniqueId] = controller
}
}
fun unregister(controller: BaseDisplayController<out Display>) {
displayControllers.remove(controller.display.uniqueId, controller)
controller.interaction?.let {
interactionControllers.remove(it.uniqueId, controller)
}
}
fun controllerCount(): Int = displayControllers.size
fun shutdown() {
displayControllers.clear()
interactionControllers.clear()
}
@EventHandler
fun onPlayerInteractEntity(event: PlayerInteractEntityEvent) {
val entity = event.rightClicked
val controller = lookupController(entity) ?: return
val surface = if (entity is Interaction) ClickSurface.INTERACTION else ClickSurface.DISPLAY
controller.handleClick(event, entity, surface)
}
@EventHandler
fun onPlayerQuit(event: PlayerQuitEvent) {
controllersSnapshot().forEach { it.handlePlayerQuit(event.player) }
}
@EventHandler
fun onPlayerJoin(event: PlayerJoinEvent) {
refreshAudiences(event.player)
}
@EventHandler
fun onPlayerChangedWorld(event: PlayerChangedWorldEvent) {
refreshAudiences(event.player)
}
@EventHandler
fun onPlayerRespawn(event: PlayerRespawnEvent) {
refreshAudiences(event.player)
}
private fun lookupController(entity: Entity): BaseDisplayController<out Display>? {
return displayControllers[entity.uniqueId]
?: interactionControllers[entity.uniqueId]
}
private fun controllersSnapshot(): Collection<BaseDisplayController<out Display>> =
displayControllers.values.toSet()
private fun refreshAudiences(player: Player) {
val controllers = controllersSnapshot()
if (controllers.isEmpty()) return
controllers.forEach { it.refreshAudience(player) }
}
}