Fake-Interaction

This commit is contained in:
Keisuke Hirata 2026-03-31 10:40:46 +09:00
parent bd6aa1743f
commit d917475e7d
9 changed files with 408 additions and 38 deletions

View File

@ -5,9 +5,9 @@ Paper 1.21.10 向けの不可視ディスプレイ制御ライブラリです。
## 提供機能
- `DisplayService` による Text/Block/Item Display の生成 API
- `DisplayController` を介した `Player#showEntity` / `hideEntity` の参照カウント管理
- `DisplayController` を介したパケットベースの表示・非表示制御(参照カウント管理)
- `AudiencePredicate`/`AudiencePredicates` による可視対象の自動同期(ログイン・ワールド移動・リスポーンで再評価)
- Interaction エンティティを用いたクリック判定サポートと、優先度付きクリックハンドラー
- FakeInteraction エンティティを用いたクリック判定サポートと、優先度付きクリックハンドラー
- まとめて `destroyAll()` できるリソース管理
## スタンドアロンでの使い方
@ -19,7 +19,7 @@ Paper 1.21.10 向けの不可視ディスプレイ制御ライブラリです。
| `/ghostdisplay create text <id>` | プレイヤー視点の約 1.5 ブロック先に TextDisplay を生成し、即座にチャット編集モードへ。次に送信したメッセージ(または `cancel`)が内容になります。 |
| `/ghostdisplay create block <id> <blockstate>` | BlockDisplay を生成します。`oak_planks[facing=north]` のような `BlockData` 文字列を指定してください。 |
| `/ghostdisplay create item <id> <material>` | ItemDisplay を生成します。`minecraft:stick` などのアイテム ID を受け付けます。 |
| `/ghostdisplay text edit <id>` | 既存の TextDisplay をチャット入力で編集。`cancel` で破棄。単語のみで良い場合は `/ghostdisplay text set <id> Hello_World` のように `_` をスペースに変換して即時更新できます。 |
| `/ghostdisplay text set <id> <content>` | TextDisplay のテキストを即時更新します。`_` はスペースに、`\n` は改行に変換されます。 |
| `/ghostdisplay viewer add|remove <id> <player/@a>` | 任意のプレイヤーまたはセレクターを表示対象に追加/削除します。`/ghostdisplay viewer clear <id>` で全員解除。 |
| `/ghostdisplay audience permission add <id> <permission>` | 指定パーミッションを持つプレイヤーに自動表示 (`remove` で解除)。 |
| `/ghostdisplay audience near set <id> <radius>` | Display 周囲の半径プレイヤーへ自動表示 (`audience clear` で全自動表示を解除)。 |
@ -28,11 +28,43 @@ Paper 1.21.10 向けの不可視ディスプレイ制御ライブラリです。
## 技術的ポイント
- Paper 標準 API の `setVisibleByDefault(false)` + `showEntity`/`hideEntity` を採用し、ProtocolLib に依存しない設計
- Display / Interaction は `setPersistent(false)` でスポーンし、サーバーリロードやチャンクアンロードに強い
- **NMS パケットベースの FakeEntity 方式** — サーバー上に実エンティティを生成せず、クライアントへのパケット送信のみで表示を実現。エンティティティック・コリジョン・永続化のオーバーヘッドがゼロ
- パケット送信はスレッドセーフなため、Folia のリージョンスケジューリングに依存しないシンプルな設計
- クリック検知は Netty パイプラインで `ServerboundInteractPacket` を傍受し、FakeInteraction の entityId にマッチしたらハンドラーを発火
- Predicate ごとにアクティブなプレイヤー集合を持つため、複数条件での重複表示にも対応
- クリック検知は `PlayerInteractEntityEvent` を介して Display/Interaction 双方から観測し、ハンドラーは Kotlin DSL で登録可能
- Kotlin 2.2 + ShadowJar + plugin-yml 生成を使い、`./gradlew build`でそのまま Paper に投入可能なJarを生成
- `paperweight-userdev` による NMS アクセス1.21.11 対応)
## API 利用例
```kotlin
val service: DisplayService = GhostDisplaysPlugin.service()
// TextDisplay を生成
val controller = service.createTextDisplay(location) {
text = Component.text("Hello")
billboard = Display.Billboard.CENTER
viewRange = 0.5f
}
controller.show(player)
// テキスト更新
controller.updateText { it.text = Component.text("Updated") }
// 共通プロパティ更新
controller.updateDisplay { it.billboard = Display.Billboard.FIXED }
// テレポート
controller.teleport(newLocation)
// Audience ルール
controller.addAudienceRule(AudiencePredicates.near(location, 30.0), AudienceAction.ADD)
// クリックハンドラー
controller.onClick { ctx -> ctx.player.sendMessage("Clicked!") }
// 破棄
controller.destroy()
```
## 同梱ライブラリ

View File

@ -1,6 +1,7 @@
package net.hareworks.ghostdisplays.api
import net.hareworks.ghostdisplays.internal.fake.FakeBlockDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeInteraction
import net.hareworks.ghostdisplays.internal.fake.FakeItemDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeTextDisplay
import org.bukkit.Location
@ -26,5 +27,10 @@ interface DisplayService {
builder: FakeItemDisplay.() -> Unit = {}
): DisplayController
fun createInteraction(
location: Location,
builder: FakeInteraction.() -> Unit = {}
): InteractionController
fun destroyAll()
}

View File

@ -0,0 +1,32 @@
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.HandlerRegistration
import net.hareworks.ghostdisplays.internal.fake.FakeInteraction
import org.bukkit.Location
import org.bukkit.entity.Player
import java.util.UUID
interface InteractionController {
val entityId: Int
val key: String?
val location: Location
fun show(player: Player)
fun hide(player: Player)
fun isViewing(playerId: UUID): Boolean
fun viewerIds(): Set<UUID>
fun updateInteraction(mutator: (FakeInteraction) -> Unit)
fun teleport(location: Location)
fun setBaseVisibility(visible: Boolean)
fun addAudience(predicate: AudiencePredicate): HandlerRegistration
fun addAudienceRule(predicate: AudiencePredicate, action: AudienceAction): HandlerRegistration
fun clearAudienceRules()
fun refreshAudience(target: Player? = null)
fun needsPeriodicUpdate(): Boolean
fun destroy()
}

View File

@ -0,0 +1,40 @@
package net.hareworks.ghostdisplays.api.event
import net.hareworks.ghostdisplays.api.InteractionController
import org.bukkit.entity.Player
import org.bukkit.event.Cancellable
import org.bukkit.event.Event
import org.bukkit.event.HandlerList
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.util.Vector
class FakeInteractionClickEvent(
val player: Player,
val interactionController: InteractionController,
val interactionEntityId: Int,
val clickType: ClickType,
val hand: EquipmentSlot?,
val clickPosition: Vector?
) : Event(), Cancellable {
val isLeftClick: Boolean get() = clickType == ClickType.LEFT
val isRightClick: Boolean get() = clickType != ClickType.LEFT
private var cancelled = false
override fun isCancelled(): Boolean = cancelled
override fun setCancelled(cancel: Boolean) { cancelled = cancel }
override fun getHandlers(): HandlerList = handlerList
enum class ClickType {
LEFT,
RIGHT,
RIGHT_AT
}
companion object {
@JvmStatic
val handlerList = HandlerList()
}
}

View File

@ -4,8 +4,10 @@ import java.util.UUID
import java.util.concurrent.CopyOnWriteArraySet
import net.hareworks.ghostdisplays.api.DisplayController
import net.hareworks.ghostdisplays.api.DisplayService
import net.hareworks.ghostdisplays.api.InteractionController
import net.hareworks.ghostdisplays.api.InteractionOptions
import net.hareworks.ghostdisplays.internal.controller.BaseDisplayController
import net.hareworks.ghostdisplays.internal.controller.BaseInteractionController
import net.hareworks.ghostdisplays.internal.controller.DisplayRegistry
import net.hareworks.ghostdisplays.internal.fake.FakeBlockDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeInteraction
@ -21,6 +23,7 @@ internal class DefaultDisplayService(
private val registry: DisplayRegistry
) : DisplayService {
private val controllers = CopyOnWriteArraySet<BaseDisplayController>()
private val interactionControllers = CopyOnWriteArraySet<BaseInteractionController>()
override fun createTextDisplay(
location: Location,
@ -66,9 +69,27 @@ internal class DefaultDisplayService(
return register(fake, location, interaction)
}
override fun createInteraction(
location: Location,
builder: FakeInteraction.() -> Unit
): InteractionController {
val fake = FakeInteraction(
entityId = DisplayPacketFactory.nextEntityId(),
uuid = UUID.randomUUID(),
location = location.clone()
)
builder(fake)
val controller = BaseInteractionController(plugin, fake, registry)
interactionControllers += controller
registry.registerInteraction(controller)
return controller
}
override fun destroyAll() {
controllers.forEach { it.destroy() }
controllers.clear()
interactionControllers.forEach { it.destroy() }
interactionControllers.clear()
}
private fun register(

View File

@ -0,0 +1,170 @@
package net.hareworks.ghostdisplays.internal.controller
import java.util.HashSet
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import net.hareworks.ghostdisplays.api.InteractionController
import net.hareworks.ghostdisplays.api.audience.AudienceAction
import net.hareworks.ghostdisplays.api.audience.AudiencePredicate
import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import net.hareworks.ghostdisplays.internal.fake.FakeInteraction
import net.hareworks.ghostdisplays.internal.nms.DisplayPacketFactory
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.entity.Player
import org.bukkit.plugin.java.JavaPlugin
internal class BaseInteractionController(
private val plugin: JavaPlugin,
val fakeInteraction: FakeInteraction,
private val registry: DisplayRegistry
) : InteractionController {
private val destroyed = AtomicBoolean(false)
private val viewerCounts = ConcurrentHashMap<UUID, Int>()
private var baseVisibility: Boolean = false
private val audienceRules = CopyOnWriteArrayList<RuleEntry>()
private val autoVisiblePlayers = ConcurrentHashMap.newKeySet<UUID>()
private var hasDynamicRules: Boolean = false
override val entityId: Int get() = fakeInteraction.entityId
override val key: String? get() = fakeInteraction.key
override val location: Location get() = fakeInteraction.location.clone()
override fun needsPeriodicUpdate(): Boolean = hasDynamicRules
override fun show(player: Player) {
val uuid = player.uniqueId
val newCount = viewerCounts.compute(uuid) { _, count -> (count ?: 0) + 1 } ?: 1
if (newCount == 1) {
DisplayPacketFactory.sendPacket(player, DisplayPacketFactory.createSpawnBundle(fakeInteraction))
}
}
override fun hide(player: Player) {
val uuid = player.uniqueId
val current = viewerCounts[uuid] ?: return
if (current <= 1) {
viewerCounts.remove(uuid)
DisplayPacketFactory.sendPacket(player, DisplayPacketFactory.createRemovePacket(fakeInteraction.entityId))
} else {
viewerCounts[uuid] = current - 1
}
}
override fun isViewing(playerId: UUID): Boolean = viewerCounts.containsKey(playerId)
override fun viewerIds(): Set<UUID> = HashSet(viewerCounts.keys)
override fun updateInteraction(mutator: (FakeInteraction) -> Unit) {
mutator(fakeInteraction)
broadcastMetadata()
}
override fun teleport(location: Location) {
fakeInteraction.location = location.clone()
val viewers = onlineViewers()
if (viewers.isEmpty()) return
val packet = DisplayPacketFactory.createTeleportPacket(fakeInteraction)
viewers.forEach { DisplayPacketFactory.sendPacket(it, packet) }
}
override fun destroy() {
if (!destroyed.compareAndSet(false, true)) return
registry.unregisterInteraction(this)
val viewers = onlineViewers()
if (viewers.isNotEmpty()) {
val removePacket = DisplayPacketFactory.createRemovePacket(fakeInteraction.entityId)
viewers.forEach { DisplayPacketFactory.sendPacket(it, removePacket) }
}
viewerCounts.clear()
audienceRules.clear()
autoVisiblePlayers.clear()
}
override fun setBaseVisibility(visible: Boolean) {
this.baseVisibility = visible
refreshAudience()
}
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)
updateDynamicStatus()
refreshAudience()
return HandlerRegistration {
audienceRules.remove(entry)
updateDynamicStatus()
refreshAudience()
}
}
override fun clearAudienceRules() {
audienceRules.clear()
refreshAudience()
}
override fun refreshAudience(target: Player?) {
val players = target?.let { listOf(it) } ?: Bukkit.getOnlinePlayers()
if (players.isEmpty()) return
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)
}
}
}
internal fun handlePlayerQuit(player: Player) {
val uuid = player.uniqueId
viewerCounts.remove(uuid)
autoVisiblePlayers.remove(uuid)
}
private fun broadcastMetadata() {
val viewers = onlineViewers()
if (viewers.isEmpty()) return
val packet = DisplayPacketFactory.createMetadataPacket(fakeInteraction)
viewers.forEach { DisplayPacketFactory.sendPacket(it, packet) }
}
private fun onlineViewers(): List<Player> =
viewerCounts.keys.mapNotNull { Bukkit.getPlayer(it) }
private fun updateDynamicStatus() {
hasDynamicRules = audienceRules.any { it.predicate.isDynamic() }
}
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
}
private data class RuleEntry(
val predicate: AudiencePredicate,
val action: AudienceAction
)
}

View File

@ -2,6 +2,8 @@ package net.hareworks.ghostdisplays.internal.controller
import java.util.concurrent.ConcurrentHashMap
import net.hareworks.ghostdisplays.api.click.ClickSurface
import net.hareworks.ghostdisplays.api.event.FakeInteractionClickEvent
import net.hareworks.ghostdisplays.internal.nms.InteractAction
import net.hareworks.ghostdisplays.internal.nms.InteractionPacketListener
import org.bukkit.Bukkit
import org.bukkit.entity.Player
@ -15,19 +17,37 @@ import org.bukkit.plugin.java.JavaPlugin
internal class DisplayRegistry : Listener {
private val displayControllers = ConcurrentHashMap<Int, BaseDisplayController>()
private val interactionControllers = ConcurrentHashMap<Int, BaseDisplayController>()
private val displayInteractionControllers = ConcurrentHashMap<Int, BaseDisplayController>()
private val standaloneInteractionControllers = ConcurrentHashMap<Int, BaseInteractionController>()
private lateinit var packetListener: InteractionPacketListener
fun initialize(plugin: JavaPlugin) {
packetListener = InteractionPacketListener(plugin) { player, entityId, isAttack ->
if (isAttack) return@InteractionPacketListener
val fromInteraction = interactionControllers[entityId]
val fromDisplay = displayControllers[entityId]
packetListener = InteractionPacketListener(plugin) { player, data ->
// スタンドアロンInteractionを先にチェック → Bukkitイベント発火
val standalone = standaloneInteractionControllers[data.entityId]
if (standalone != null) {
val clickType = when (data.action) {
InteractAction.ATTACK -> FakeInteractionClickEvent.ClickType.LEFT
InteractAction.INTERACT -> FakeInteractionClickEvent.ClickType.RIGHT
InteractAction.INTERACT_AT -> FakeInteractionClickEvent.ClickType.RIGHT_AT
}
val loc = standalone.fakeInteraction.location
plugin.server.regionScheduler.execute(plugin, loc) {
val event = FakeInteractionClickEvent(player, standalone, data.entityId, clickType, data.hand, data.clickPosition)
Bukkit.getPluginManager().callEvent(event)
}
return@InteractionPacketListener
}
// Display付属Interactionの場合は既存のクリックハンドラ右クリックのみ
if (data.action == InteractAction.ATTACK) return@InteractionPacketListener
val fromInteraction = displayInteractionControllers[data.entityId]
val fromDisplay = displayControllers[data.entityId]
val controller = fromInteraction ?: fromDisplay ?: return@InteractionPacketListener
val surface = if (fromInteraction != null) ClickSurface.INTERACTION else ClickSurface.DISPLAY
val loc = controller.fakeDisplay.location
plugin.server.regionScheduler.execute(plugin, loc) {
controller.handleClick(player, entityId, surface)
controller.handleClick(player, data.entityId, surface)
}
}
packetListener.injectAll()
@ -37,27 +57,37 @@ internal class DisplayRegistry : Listener {
fun register(controller: BaseDisplayController) {
displayControllers[controller.fakeDisplay.entityId] = controller
controller.fakeInteraction?.let {
interactionControllers[it.entityId] = controller
displayInteractionControllers[it.entityId] = controller
}
}
fun unregister(controller: BaseDisplayController) {
displayControllers.remove(controller.fakeDisplay.entityId, controller)
controller.fakeInteraction?.let {
interactionControllers.remove(it.entityId, controller)
displayInteractionControllers.remove(it.entityId, controller)
}
}
fun controllerCount(): Int = displayControllers.size
fun registerInteraction(controller: BaseInteractionController) {
standaloneInteractionControllers[controller.fakeInteraction.entityId] = controller
}
fun unregisterInteraction(controller: BaseInteractionController) {
standaloneInteractionControllers.remove(controller.fakeInteraction.entityId, controller)
}
fun controllerCount(): Int = displayControllers.size + standaloneInteractionControllers.size
fun shutdown() {
displayControllers.clear()
interactionControllers.clear()
displayInteractionControllers.clear()
standaloneInteractionControllers.clear()
}
@EventHandler
fun onPlayerQuit(event: PlayerQuitEvent) {
controllersSnapshot().forEach { it.handlePlayerQuit(event.player) }
displayControllersSnapshot().forEach { it.handlePlayerQuit(event.player) }
standaloneInteractionsSnapshot().forEach { it.handlePlayerQuit(event.player) }
}
@EventHandler
@ -75,26 +105,28 @@ internal class DisplayRegistry : Listener {
refreshAudiences(event.player)
}
private fun controllersSnapshot(): Collection<BaseDisplayController> =
private fun displayControllersSnapshot(): Collection<BaseDisplayController> =
displayControllers.values.toSet()
private fun standaloneInteractionsSnapshot(): Collection<BaseInteractionController> =
standaloneInteractionControllers.values.toSet()
private fun refreshAudiences(player: Player) {
val controllers = controllersSnapshot()
if (controllers.isEmpty()) return
controllers.forEach { it.refreshAudience(player) }
displayControllersSnapshot().forEach { it.refreshAudience(player) }
standaloneInteractionsSnapshot().forEach { it.refreshAudience(player) }
}
fun updateAudiences() {
val players = Bukkit.getOnlinePlayers()
if (players.isEmpty()) return
val controllers = controllersSnapshot()
if (controllers.isEmpty()) return
val activeControllers = controllers.filter { it.needsPeriodicUpdate() }
if (activeControllers.isEmpty()) return
val activeDisplays = displayControllersSnapshot().filter { it.needsPeriodicUpdate() }
val activeInteractions = standaloneInteractionsSnapshot().filter { it.needsPeriodicUpdate() }
if (activeDisplays.isEmpty() && activeInteractions.isEmpty()) return
players.forEach { player ->
activeControllers.forEach { controller ->
controller.refreshAudience(player)
}
activeDisplays.forEach { it.refreshAudience(player) }
activeInteractions.forEach { it.refreshAudience(player) }
}
}
}

View File

@ -9,6 +9,7 @@ class FakeInteraction(
location: Location
) : FakeEntity(entityId, uuid, location) {
var key: String? = null
var width: Float = 0.8f
var height: Float = 0.8f
var responsive: Boolean = true

View File

@ -5,17 +5,32 @@ import io.netty.channel.ChannelHandlerContext
import java.lang.invoke.MethodHandles
import java.util.logging.Level
import net.minecraft.network.protocol.game.ServerboundInteractPacket
import net.minecraft.world.InteractionHand
import org.bukkit.craftbukkit.entity.CraftPlayer
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.plugin.java.JavaPlugin
internal data class InteractData(
val entityId: Int,
val action: InteractAction,
val hand: EquipmentSlot?,
val clickPosition: org.bukkit.util.Vector?
)
internal enum class InteractAction {
ATTACK,
INTERACT,
INTERACT_AT
}
internal class InteractionPacketListener(
private val plugin: JavaPlugin,
private val onInteract: (player: Player, entityId: Int, isAttack: Boolean) -> Unit
private val onInteract: (player: Player, data: InteractData) -> Unit
) : Listener {
companion object {
@ -71,8 +86,8 @@ internal class InteractionPacketListener(
if (msg is ServerboundInteractPacket) {
try {
val entityId = ENTITY_ID_GETTER.invoke(msg) as Int
val isAttack = isAttackAction(msg)
onInteract(player, entityId, isAttack)
val data = extractAction(entityId, msg)
onInteract(player, data)
} catch (ex: Throwable) {
plugin.logger.log(Level.WARNING, "Failed to handle interact packet", ex)
}
@ -82,13 +97,34 @@ internal class InteractionPacketListener(
}
}
private fun isAttackAction(packet: ServerboundInteractPacket): Boolean {
var attack = false
private fun extractAction(entityId: Int, packet: ServerboundInteractPacket): InteractData {
var action = InteractAction.ATTACK
var hand: EquipmentSlot? = null
var clickPos: org.bukkit.util.Vector? = null
packet.dispatch(object : ServerboundInteractPacket.Handler {
override fun onInteraction(hand: net.minecraft.world.InteractionHand) {}
override fun onInteraction(hand: net.minecraft.world.InteractionHand, pos: net.minecraft.world.phys.Vec3) {}
override fun onAttack() { attack = true }
override fun onInteraction(nmsHand: InteractionHand) {
action = InteractAction.INTERACT
hand = toBukkit(nmsHand)
}
override fun onInteraction(nmsHand: InteractionHand, pos: net.minecraft.world.phys.Vec3) {
action = InteractAction.INTERACT_AT
hand = toBukkit(nmsHand)
clickPos = org.bukkit.util.Vector(pos.x, pos.y, pos.z)
}
override fun onAttack() {
action = InteractAction.ATTACK
hand = null
}
})
return attack
return InteractData(entityId, action, hand, clickPos)
}
private fun toBukkit(hand: InteractionHand): EquipmentSlot = when (hand) {
InteractionHand.MAIN_HAND -> EquipmentSlot.HAND
InteractionHand.OFF_HAND -> EquipmentSlot.OFF_HAND
}
}