feat: Audienceのタイミング

This commit is contained in:
Keisuke Hirata 2025-12-07 03:41:42 +09:00
parent 4a302f5f6f
commit bc774765c5
17 changed files with 318 additions and 72 deletions

4
.gitignore vendored
View File

@ -1,5 +1,3 @@
# Ignore Gradle project-specific cache directory
.gradle
# Ignore Gradle build output directory
bin
build

@ -1 +1 @@
Subproject commit 559341c0415699a2b39a1a523f4ef4ed93ce3fdc
Subproject commit 9af293122b860d8c65d68a2bdc02a9c2c5e206cc

View File

@ -1,5 +1,6 @@
package net.hareworks.ghostdisplays.api
import net.hareworks.ghostdisplays.api.audience.AudienceAction
import net.hareworks.ghostdisplays.api.audience.AudiencePredicate
import net.hareworks.ghostdisplays.api.click.ClickPriority
import net.hareworks.ghostdisplays.api.click.DisplayClickHandler
@ -23,8 +24,24 @@ interface DisplayController<T : Display> {
fun applyEntityUpdate(mutator: (T) -> Unit)
/**
* Displayの基本可視状態を設定します
* ルールにマッチしなかった場合のデフォルトの振る舞いとなります
*/
fun setBaseVisibility(visible: Boolean)
/**
* 従来の簡易メソッドAction=ADD として登録します
*/
fun addAudience(predicate: AudiencePredicate): HandlerRegistration
/**
* ルールを追加します評価順序は追加順です
*/
fun addAudienceRule(predicate: AudiencePredicate, action: AudienceAction): HandlerRegistration
fun clearAudienceRules()
fun refreshAudience(target: Player? = null)
fun destroy()

View File

@ -0,0 +1,16 @@
package net.hareworks.ghostdisplays.api.audience
/**
* Predicateの評価結果に対するアクション
*/
enum class AudienceAction {
/**
* 表示対象に追加する (Visible = true)
*/
ADD,
/**
* 表示対象から除外する (Visible = false)
*/
EXCLUDE
}

View File

@ -1,5 +1,6 @@
package net.hareworks.ghostdisplays.api.audience
import org.bukkit.Location
import org.bukkit.entity.Player
import java.util.UUID
@ -19,7 +20,15 @@ object AudiencePredicates {
player.uniqueId == target
}
fun players(targets: Collection<UUID>): AudiencePredicate {
val set = targets.toSet()
return AudiencePredicate { player ->
player.uniqueId in set
}
}
fun near(location: Location, radius: Double): AudiencePredicate {
val radiusSq = radius * radius
val worldName = location.world?.name
require(worldName != null) { "Location must have a world" }

View File

@ -111,6 +111,9 @@ object CommandRegistrar {
literal("delete") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
executes {
val id = argument<String>("id")
try {
@ -139,6 +142,9 @@ object CommandRegistrar {
literal("info") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
executes {
val id = argument<String>("id")
val display = manager.findDisplay(id)
@ -161,6 +167,9 @@ object CommandRegistrar {
literal("viewer") {
literal("add") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
players("targets") {
executes {
val id = argument<String>("id")
@ -177,6 +186,9 @@ object CommandRegistrar {
}
literal("remove") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
players("targets") {
executes {
val id = argument<String>("id")
@ -193,6 +205,9 @@ object CommandRegistrar {
}
literal("clear") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
executes {
val id = argument<String>("id")
try {
@ -209,6 +224,12 @@ object CommandRegistrar {
literal("text") {
literal("edit") {
string("id") {
suggests { prefix ->
manager.listDisplays()
.filter { it.kind == DisplayKind.TEXT }
.map { it.id }
.filter { it.startsWith(prefix, ignoreCase = true) }
}
executes {
val player = sender.requirePlayer() ?: return@executes
val id = argument<String>("id")
@ -224,6 +245,12 @@ object CommandRegistrar {
}
literal("set") {
string("id") {
suggests { prefix ->
manager.listDisplays()
.filter { it.kind == DisplayKind.TEXT }
.map { it.id }
.filter { it.startsWith(prefix, ignoreCase = true) }
}
string("content") {
executes {
val id = argument<String>("id")
@ -254,6 +281,12 @@ object CommandRegistrar {
literal("block") {
literal("set") {
string("id") {
suggests { prefix ->
manager.listDisplays()
.filter { it.kind == DisplayKind.BLOCK }
.map { it.id }
.filter { it.startsWith(prefix, ignoreCase = true) }
}
string("state") {
executes {
val id = argument<String>("id")
@ -276,6 +309,12 @@ object CommandRegistrar {
literal("item") {
literal("set") {
string("id") {
suggests { prefix ->
manager.listDisplays()
.filter { it.kind == DisplayKind.ITEM }
.map { it.id }
.filter { it.startsWith(prefix, ignoreCase = true) }
}
string("material") {
executes {
val id = argument<String>("id")
@ -298,16 +337,54 @@ object CommandRegistrar {
}
literal("audience") {
literal("base") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
bool("visible") {
executes {
val id = argument<String>("id")
val visible = argument<Boolean>("visible")
try {
manager.setBaseVisibility(id, visible)
sender.success("Base visibility for '$id' set to $visible.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to set base visibility.")
}
}
}
}
}
literal("permission") {
literal("add") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
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'.")
sender.success("Permission audience '$perm' added to '$id' (ADD).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to add permission audience.")
}
}
string("action") {
suggests { listOf("ADD", "EXCLUDE") }
executes {
val id = argument<String>("id")
val perm = argument<String>("permission")
val actionName = argument<String>("action").uppercase()
val action = runCatching { net.hareworks.ghostdisplays.api.audience.AudienceAction.valueOf(actionName) }
.getOrElse { return@executes sender.failure("Invalid action '$actionName'. Use ADD or EXCLUDE.") }
try {
manager.addPermissionAudience(id, perm, action)
sender.success("Permission audience '$perm' added to '$id' ($action).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to add permission audience.")
}
@ -315,8 +392,12 @@ object CommandRegistrar {
}
}
}
}
literal("remove") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
string("permission") {
executes {
val id = argument<String>("id")
@ -337,13 +418,31 @@ object CommandRegistrar {
literal("near") {
literal("set") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
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).")
sender.success("Radius audience for '$id' set to ${String.format("%.1f", radius)} block(s) (ADD).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update radius audience.")
}
}
string("action") {
suggests { listOf("ADD", "EXCLUDE") }
executes {
val id = argument<String>("id")
val radius = argument<Double>("radius")
val actionName = argument<String>("action").uppercase()
val action = runCatching { net.hareworks.ghostdisplays.api.audience.AudienceAction.valueOf(actionName) }
.getOrElse { return@executes sender.failure("Invalid action '$actionName'. Use ADD or EXCLUDE.") }
try {
manager.setNearAudience(id, radius, action)
sender.success("Radius audience for '$id' set to ${String.format("%.1f", radius)} block(s) ($action).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update radius audience.")
}
@ -352,8 +451,67 @@ object CommandRegistrar {
}
}
}
}
literal("player") {
literal("add") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
players("targets") {
executes {
val id = argument<String>("id")
val targets = argument<List<Player>>("targets")
try {
targets.forEach { manager.addPlayerAudience(id, it.uniqueId, it.name) }
sender.success("Added ${targets.size} player(s) to audience of '$id' (ADD).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to add player audience.")
}
}
string("action") {
suggests { listOf("ADD", "EXCLUDE") }
executes {
val id = argument<String>("id")
val targets = argument<List<Player>>("targets")
val actionName = argument<String>("action").uppercase()
val action = runCatching { net.hareworks.ghostdisplays.api.audience.AudienceAction.valueOf(actionName) }
.getOrElse { return@executes sender.failure("Invalid action '$actionName'. Use ADD or EXCLUDE.") }
try {
targets.forEach { manager.addPlayerAudience(id, it.uniqueId, it.name, action) }
sender.success("Added ${targets.size} player(s) to audience of '$id' ($action).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to add player audience.")
}
}
}
}
}
}
literal("remove") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
players("targets") {
executes {
val id = argument<String>("id")
val targets = argument<List<Player>>("targets")
var count = 0
targets.forEach {
if (manager.removePlayerAudience(id, it.uniqueId)) count++
}
sender.success("Removed $count/${targets.size} player(s) from audience of '$id'.")
}
}
}
}
}
literal("clear") {
string("id") {
suggests { prefix ->
manager.listDisplays().map { it.id }.filter { it.startsWith(prefix, ignoreCase = true) }
}
executes {
val id = argument<String>("id")
try {
@ -379,8 +537,11 @@ private fun CommandSender.showUsage() {
info(" /ghostdisplay list | info <id> | delete <id>")
}
private fun Player.anchorLocation(): Location =
eyeLocation.clone().add(direction.normalize().multiply(1.5))
private fun Player.anchorLocation(): Location {
val eye = eyeLocation.clone()
val forward = eye.direction.normalize().multiply(1.5)
return eye.add(forward)
}
private fun parseBlockData(state: String): BlockData =
Bukkit.createBlockData(state)

View File

@ -6,6 +6,7 @@ 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.AudienceAction
import net.hareworks.ghostdisplays.api.audience.AudiencePredicates
import net.hareworks.ghostdisplays.display.DisplayManager.DisplayOperationException
import net.kyori.adventure.text.Component
@ -79,7 +80,7 @@ class DisplayManager(
ensureIdAvailable(normalized)
val safeLocation = location.clone()
val controller = service.createItemDisplay(safeLocation, itemStack.clone(), INTERACTION_DEFAULT)
controller.applyEntityUpdate { it.itemStack = itemStack.clone() }
controller.applyEntityUpdate { it.setItemStack(itemStack.clone()) }
val managed = ManagedDisplay.Item(
id = normalized,
controller = controller,
@ -115,7 +116,7 @@ class DisplayManager(
fun updateItem(id: String, itemStack: ItemStack) {
val display = requireItem(id)
val clone = itemStack.clone()
display.controller.applyEntityUpdate { it.itemStack = clone }
display.controller.applyEntityUpdate { it.setItemStack(clone) }
display.itemStack = clone
}
@ -142,17 +143,22 @@ class DisplayManager(
viewers.forEach { display.controller.hide(it) }
}
fun addPermissionAudience(id: String, permission: String) {
fun setBaseVisibility(id: String, visible: Boolean) {
val display = requireDisplay(id)
display.controller.setBaseVisibility(visible)
}
fun addPermissionAudience(id: String, permission: String, action: AudienceAction = AudienceAction.ADD) {
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))
val registration = display.controller.addAudienceRule(AudiencePredicates.permission(permission), action)
display.registerAudienceBinding(
AudienceBinding(
key = key,
description = "permission:$permission",
description = "permission:$permission [${action.name}]",
registration = registration
)
)
@ -164,24 +170,45 @@ class DisplayManager(
return display.removeAudienceBinding(key)
}
fun setNearAudience(id: String, radius: Double) {
fun setNearAudience(id: String, radius: Double, action: AudienceAction = AudienceAction.ADD) {
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))
val registration = display.controller.addAudienceRule(AudiencePredicates.near(display.location, radius), action)
display.registerAudienceBinding(
AudienceBinding(
key = key,
description = "radius:${String.format("%.2f", radius)}",
description = "radius:${String.format("%.2f", radius)} [${action.name}]",
registration = registration
)
)
}
fun addPlayerAudience(id: String, playerId: UUID, playerName: String, action: AudienceAction = AudienceAction.ADD) {
val display = requireDisplay(id)
val key = "player:$playerId"
display.removeAudienceBinding(key)
val registration = display.controller.addAudienceRule(AudiencePredicates.uuid(playerId), action)
display.registerAudienceBinding(
AudienceBinding(
key = key,
description = "player:$playerName [${action.name}]",
registration = registration
)
)
}
fun removePlayerAudience(id: String, playerId: UUID): Boolean {
val display = requireDisplay(id)
val key = "player:$playerId"
return display.removeAudienceBinding(key)
}
fun clearAudiences(id: String) {
val display = requireDisplay(id)
display.clearAudiences()
display.controller.clearAudienceRules()
}
fun destroyAll() {

View File

@ -37,7 +37,7 @@ class EditSessionManager(
event.player.sendMessage("GhostDisplays: editing for '$displayId' cancelled.")
return
}
Bukkit.getScheduler().runTask(plugin) {
Bukkit.getScheduler().runTask(plugin, Runnable {
try {
manager.updateText(displayId, message)
event.player.sendMessage("GhostDisplays: updated text for '$displayId'.")
@ -46,7 +46,7 @@ class EditSessionManager(
} finally {
sessions.remove(event.player.uniqueId)
}
}
})
}
@EventHandler

View File

@ -46,7 +46,7 @@ internal class DefaultDisplayService(
interaction: InteractionOptions,
builder: ItemDisplay.() -> Unit
): DisplayController<ItemDisplay> = spawnDisplay(location, ItemDisplay::class.java, interaction) {
it.itemStack = itemStack.clone()
it.setItemStack(itemStack.clone())
builder(it)
}
@ -94,7 +94,7 @@ internal class DefaultDisplayService(
action()
} else {
val future = CompletableFuture<T>()
Bukkit.getScheduler().runTask(plugin) { future.complete(action()) }
Bukkit.getScheduler().runTask(plugin, Runnable { future.complete(action()) })
future.join()
}
}

View File

@ -7,6 +7,7 @@ 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.AudienceAction
import net.hareworks.ghostdisplays.api.audience.AudiencePredicate
import net.hareworks.ghostdisplays.api.click.ClickPriority
import net.hareworks.ghostdisplays.api.click.ClickSurface
@ -30,7 +31,11 @@ internal class BaseDisplayController<T : Display>(
private val destroyed = AtomicBoolean(false)
private val viewerCounts = ConcurrentHashMap<UUID, Int>()
private val handlers = CopyOnWriteArrayList<HandlerEntry>()
private val audiences = CopyOnWriteArrayList<AudienceBindingImpl>()
// Audience Management
private var baseVisibility: Boolean = false
private val audienceRules = CopyOnWriteArrayList<RuleEntry>()
private val autoVisiblePlayers = ConcurrentHashMap.newKeySet<UUID>()
override fun show(player: Player) {
runSync {
@ -64,7 +69,6 @@ internal class BaseDisplayController<T : Display>(
override fun applyEntityUpdate(mutator: (T) -> Unit) {
callSync {
mutator(display)
display.updateDisplay(false)
}
}
@ -82,8 +86,8 @@ internal class BaseDisplayController<T : Display>(
viewerCounts.clear()
}
handlers.clear()
audiences.forEach { it.clear() }
audiences.clear()
audienceRules.clear()
autoVisiblePlayers.clear()
}
override fun onClick(priority: ClickPriority, handler: DisplayClickHandler): HandlerRegistration {
@ -94,26 +98,67 @@ internal class BaseDisplayController<T : Display>(
}
}
override fun addAudience(predicate: AudiencePredicate): HandlerRegistration {
val binding = AudienceBindingImpl(predicate)
audiences += binding
refreshAudienceInternal(binding = binding)
return HandlerRegistration { binding.unregister() }
override fun setBaseVisibility(visible: Boolean) {
this.baseVisibility = visible
refreshAudience()
}
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 addAudience(predicate: AudiencePredicate): HandlerRegistration {
return addAudienceRule(predicate, AudienceAction.ADD)
}
override fun addAudienceRule(predicate: AudiencePredicate, action: AudienceAction): HandlerRegistration {
val entry = RuleEntry(predicate, action)
audienceRules.add(entry)
refreshAudience()
return HandlerRegistration {
audienceRules.remove(entry)
refreshAudience()
}
}
override fun clearAudienceRules() {
audienceRules.clear()
refreshAudience()
}
override fun refreshAudience(target: Player?) {
refreshAudienceInternal(target, null)
refreshAudienceInternal(target)
}
private fun refreshAudienceInternal(target: Player? = null) {
runSync {
val players = target?.let { listOf(it) } ?: Bukkit.getOnlinePlayers()
if (players.isEmpty()) return@runSync
players.forEach { player ->
val uuid = player.uniqueId
val shouldBeVisible = evaluateVisibility(player)
val isCurrentlyAutoVisible = autoVisiblePlayers.contains(uuid)
if (shouldBeVisible && !isCurrentlyAutoVisible) {
autoVisiblePlayers.add(uuid)
show(player)
} else if (!shouldBeVisible && isCurrentlyAutoVisible) {
autoVisiblePlayers.remove(uuid)
hide(player)
}
}
}
}
private fun evaluateVisibility(player: Player): Boolean {
var visible = baseVisibility
for (rule in audienceRules) {
val matches = runCatching { rule.predicate.test(player) }.getOrDefault(false)
if (matches) {
when (rule.action) {
AudienceAction.ADD -> visible = true
AudienceAction.EXCLUDE -> visible = false
}
}
}
return visible
}
internal fun handleClick(event: PlayerInteractEntityEvent, clicked: Entity, surface: ClickSurface) {
@ -136,14 +181,17 @@ internal class BaseDisplayController<T : Display>(
internal fun handlePlayerQuit(player: Player) {
val uuid = player.uniqueId
viewerCounts.remove(uuid)
audiences.forEach { it.forget(uuid) }
autoVisiblePlayers.remove(uuid)
// Note: ref count logic doesn't strictly need persistent cleanup if we remove from counts,
// but removing from autoVisiblePlayers ensures we don't think we're showing it if they rejoin?
// Actually, if they quit, we should probably clear for them.
}
private fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().runTask(plugin, action)
Bukkit.getScheduler().runTask(plugin, Runnable { action() })
}
}
@ -152,45 +200,15 @@ internal class BaseDisplayController<T : Display>(
action()
} else {
val future = CompletableFuture<R>()
Bukkit.getScheduler().runTask(plugin) { future.complete(action()) }
Bukkit.getScheduler().runTask(plugin, Runnable { 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 RuleEntry(
val predicate: AudiencePredicate,
val action: AudienceAction
)
private data class HandlerEntry(
val priority: ClickPriority,