Compare commits

..
17 Commits
Author SHA1 Message Date
Hare 725ba737ae bump 26.1 2026-04-27 17:37:50 +09:00
Hare fa5142290c glowサポート 2026-04-08 01:27:37 +09:00
Hare d917475e7d Fake-Interaction 2026-03-31 10:40:46 +09:00
Hare bd6aa1743f FakeEntity化 2026-03-30 19:38:18 +09:00
Hare b985131011 feat: folia対応 2026-03-11 19:38:36 +09:00
Hare 15050e98d3 chore: update kommand-lib 2026-03-05 02:17:36 +09:00
Hare a072cdf725 chore: 単体ビルド時のincludeを修正 2026-03-03 18:46:32 +09:00
Hare 1bc8fbc888 chore: 恐らくちゃんとしたビルド設定 2025-12-12 01:11:21 +09:00
Hare a758ab11bc chore: relocate 2025-12-09 18:48:40 +09:00
Hare bfa6a2348b chore: includeBuild 2025-12-09 16:13:18 +09:00
Hare 5535c25949 chore: paperLibrary 2025-12-09 15:29:25 +09:00
Hare 285e76fd65 feat: stdlibのjar除外 2025-12-09 13:26:34 +09:00
Hare 7956778518 feat: 非parent時のincludeBuild防止 2025-12-09 13:20:53 +09:00
Hare be85d83428 chore: kommandアップデート 2025-12-09 12:32:27 +09:00
Hare dd3a066a47 feat: placeholder API 仮対応 2025-12-09 10:52:35 +09:00
Hare f3ea947310 feat: エディットセッションを削除 2025-12-07 07:20:30 +09:00
Hare b3bdc95f63 feat: 最適化とKommandアップデート 2025-12-07 07:14:46 +09:00
31 changed files with 1363 additions and 411 deletions
+39 -7
View File
@@ -5,9 +5,9 @@ Paper 1.21.10 向けの不可視ディスプレイ制御ライブラリです。
## 提供機能 ## 提供機能
- `DisplayService` による Text/Block/Item Display の生成 API - `DisplayService` による Text/Block/Item Display の生成 API
- `DisplayController` を介した `Player#showEntity` / `hideEntity`参照カウント管理 - `DisplayController` を介したパケットベースの表示・非表示制御(参照カウント管理
- `AudiencePredicate`/`AudiencePredicates` による可視対象の自動同期(ログイン・ワールド移動・リスポーンで再評価) - `AudiencePredicate`/`AudiencePredicates` による可視対象の自動同期(ログイン・ワールド移動・リスポーンで再評価)
- Interaction エンティティを用いたクリック判定サポートと、優先度付きクリックハンドラー - FakeInteraction エンティティを用いたクリック判定サポートと、優先度付きクリックハンドラー
- まとめて `destroyAll()` できるリソース管理 - まとめて `destroyAll()` できるリソース管理
## スタンドアロンでの使い方 ## スタンドアロンでの使い方
@@ -19,7 +19,7 @@ Paper 1.21.10 向けの不可視ディスプレイ制御ライブラリです。
| `/ghostdisplay create text <id>` | プレイヤー視点の約 1.5 ブロック先に TextDisplay を生成し、即座にチャット編集モードへ。次に送信したメッセージ(または `cancel`)が内容になります。 | | `/ghostdisplay create text <id>` | プレイヤー視点の約 1.5 ブロック先に TextDisplay を生成し、即座にチャット編集モードへ。次に送信したメッセージ(または `cancel`)が内容になります。 |
| `/ghostdisplay create block <id> <blockstate>` | BlockDisplay を生成します。`oak_planks[facing=north]` のような `BlockData` 文字列を指定してください。 | | `/ghostdisplay create block <id> <blockstate>` | BlockDisplay を生成します。`oak_planks[facing=north]` のような `BlockData` 文字列を指定してください。 |
| `/ghostdisplay create item <id> <material>` | ItemDisplay を生成します。`minecraft:stick` などのアイテム ID を受け付けます。 | | `/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 viewer add|remove <id> <player/@a>` | 任意のプレイヤーまたはセレクターを表示対象に追加/削除します。`/ghostdisplay viewer clear <id>` で全員解除。 |
| `/ghostdisplay audience permission add <id> <permission>` | 指定パーミッションを持つプレイヤーに自動表示 (`remove` で解除)。 | | `/ghostdisplay audience permission add <id> <permission>` | 指定パーミッションを持つプレイヤーに自動表示 (`remove` で解除)。 |
| `/ghostdisplay audience near set <id> <radius>` | Display 周囲の半径プレイヤーへ自動表示 (`audience clear` で全自動表示を解除)。 | | `/ghostdisplay audience near set <id> <radius>` | Display 周囲の半径プレイヤーへ自動表示 (`audience clear` で全自動表示を解除)。 |
@@ -28,11 +28,43 @@ Paper 1.21.10 向けの不可視ディスプレイ制御ライブラリです。
## 技術的ポイント ## 技術的ポイント
- Paper 標準 API の `setVisibleByDefault(false)` + `showEntity`/`hideEntity` を採用し、ProtocolLib に依存しない設計 - **NMS パケットベースの FakeEntity 方式** — サーバー上に実エンティティを生成せず、クライアントへのパケット送信のみで表示を実現。エンティティティック・コリジョン・永続化のオーバーヘッドがゼロ
- Display / Interaction は `setPersistent(false)` でスポーンし、サーバーリロードやチャンクアンロードに強い - パケット送信はスレッドセーフなため、Folia のリージョンスケジューリングに依存しないシンプルな設計
- クリック検知は Netty パイプラインで `ServerboundInteractPacket` を傍受し、FakeInteraction の entityId にマッチしたらハンドラーを発火
- Predicate ごとにアクティブなプレイヤー集合を持つため、複数条件での重複表示にも対応 - Predicate ごとにアクティブなプレイヤー集合を持つため、複数条件での重複表示にも対応
- クリック検知は `PlayerInteractEntityEvent` を介して Display/Interaction 双方から観測し、ハンドラーは Kotlin DSL で登録可能 - `paperweight-userdev` による NMS アクセス(1.21.11 対応)
- Kotlin 2.2 + ShadowJar + plugin-yml 生成を使い、`./gradlew build`でそのまま Paper に投入可能なJarを生成
## 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()
```
## 同梱ライブラリ ## 同梱ライブラリ
+33 -10
View File
@@ -1,36 +1,55 @@
import io.papermc.paperweight.userdev.ReobfArtifactConfiguration
import net.minecrell.pluginyml.paper.PaperPluginDescription
group = "net.hareworks" group = "net.hareworks"
version = "1.0" version = "1.0"
plugins { plugins {
kotlin("jvm") version "2.2.21" kotlin("jvm") version "2.3.21"
id("de.eldoria.plugin-yml.paper") version "0.8.0" id("de.eldoria.plugin-yml.paper") version "0.9.0"
id("com.gradleup.shadow") version "9.2.2" id("com.gradleup.shadow") version "9.2.2"
id("io.papermc.paperweight.userdev") version "2.0.0-beta.21"
} }
repositories { repositories {
mavenCentral() mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/") maven("https://repo.papermc.io/repository/maven-public/")
maven("https://repo.extendedclip.com/content/repositories/placeholderapi/")
} }
paperweight.reobfArtifactConfiguration = ReobfArtifactConfiguration.MOJANG_PRODUCTION
dependencies { dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") compileOnly("me.clip:placeholderapi:2.11.6")
implementation("org.jetbrains.kotlin:kotlin-stdlib") paperweight.paperDevBundle("26.1.2.build.49-beta")
compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.1.0")
implementation("net.hareworks:kommand-lib:1.1") implementation("net.hareworks:kommand-lib:1.1")
implementation("net.hareworks:permits-lib:1.1") implementation("net.hareworks:permits-lib:1.1")
implementation("net.kyori:adventure-text-minimessage:4.17.0") compileOnly("net.kyori:adventure-text-minimessage:4.26.1")
implementation("net.kyori:adventure-text-serializer-plain:4.17.0") compileOnly("net.kyori:adventure-text-serializer-plain:4.26.1")
} }
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(25)
} }
paper { paper {
main = "net.hareworks.ghostdisplays.GhostDisplaysPlugin" main = "net.hareworks.ghostdisplays.GhostDisplaysPlugin"
name = "GhostDisplays" name = "GhostDisplays"
version = project.version as String version = project.version as String
apiVersion = "1.21" apiVersion = "26.1"
foliaSupported = true
description = "Invisible display entity controller library." description = "Invisible display entity controller library."
authors = listOf("Hareworks") authors = listOf("Hareworks")
serverDependencies {
register("PlaceholderAPI") {
required = false
load = PaperPluginDescription.RelativeLoadOrder.BEFORE
}
register("hcu-core") {
required = true
load = PaperPluginDescription.RelativeLoadOrder.BEFORE
}
}
} }
tasks { tasks {
withType<Jar> { withType<Jar> {
@@ -39,8 +58,12 @@ tasks {
shadowJar { shadowJar {
archiveClassifier.set("min") archiveClassifier.set("min")
minimize { minimize {
exclude("net.hareworks:kommand-lib") exclude(dependency("net.hareworks:kommand-lib"))
exclude("net.hareworks:permits-lib") exclude(dependency("net.hareworks:permits-lib"))
exclude(dependency("org.jetbrains.kotlin:kotlin-stdlib"))
exclude(dependency("org.jetbrains.kotlin:kotlin-reflect"))
exclude(dependency("org.jetbrains.kotlin:kotlin-stdlib-jdk8"))
exclude(dependency("org.jetbrains.kotlin:kotlin-stdlib-jdk7"))
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+6 -6
View File
@@ -1,9 +1,9 @@
/* pluginManagement {
* This file was generated by the Gradle 'init' task. repositories {
* gradlePluginPortal()
* The settings file is used to specify which projects to include in your build. maven("https://repo.papermc.io/repository/maven-public/")
* For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.14.3/userguide/multi_project_builds.html in the Gradle documentation. }
*/ }
rootProject.name = "GhostDisplays" rootProject.name = "GhostDisplays"
@@ -3,7 +3,6 @@ package net.hareworks.ghostdisplays
import net.hareworks.ghostdisplays.api.DisplayService import net.hareworks.ghostdisplays.api.DisplayService
import net.hareworks.ghostdisplays.command.CommandRegistrar import net.hareworks.ghostdisplays.command.CommandRegistrar
import net.hareworks.ghostdisplays.display.DisplayManager import net.hareworks.ghostdisplays.display.DisplayManager
import net.hareworks.ghostdisplays.display.EditSessionManager
import net.hareworks.ghostdisplays.internal.DefaultDisplayService import net.hareworks.ghostdisplays.internal.DefaultDisplayService
import net.hareworks.ghostdisplays.internal.controller.DisplayRegistry import net.hareworks.ghostdisplays.internal.controller.DisplayRegistry
import net.hareworks.kommand_lib.KommandLib import net.hareworks.kommand_lib.KommandLib
@@ -17,7 +16,6 @@ class GhostDisplaysPlugin : JavaPlugin() {
private lateinit var displayRegistry: DisplayRegistry private lateinit var displayRegistry: DisplayRegistry
private lateinit var serviceImpl: DisplayService private lateinit var serviceImpl: DisplayService
private lateinit var displayManager: DisplayManager private lateinit var displayManager: DisplayManager
private lateinit var editSessions: EditSessionManager
private lateinit var miniMessage: MiniMessage private lateinit var miniMessage: MiniMessage
private var permissionSession: MutationSession? = null private var permissionSession: MutationSession? = null
private var kommand: KommandLib? = null private var kommand: KommandLib? = null
@@ -26,29 +24,26 @@ class GhostDisplaysPlugin : JavaPlugin() {
miniMessage = MiniMessage.miniMessage() miniMessage = MiniMessage.miniMessage()
displayRegistry = DisplayRegistry().also { displayRegistry = DisplayRegistry().also {
server.pluginManager.registerEvents(it, this) server.pluginManager.registerEvents(it, this)
it.initialize(this)
} }
serviceImpl = DefaultDisplayService(this, displayRegistry) serviceImpl = DefaultDisplayService(this, displayRegistry)
displayManager = DisplayManager(this, serviceImpl, miniMessage) 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) server.servicesManager.register(DisplayService::class.java, serviceImpl, this, ServicePriority.Normal)
permissionSession = PermitsLib.session(this) permissionSession = PermitsLib.session(this)
kommand = CommandRegistrar.register(this, displayManager, editSessions, permissionSession!!) kommand = CommandRegistrar.register(this, displayManager, permissionSession!!)
instance = this instance = this
logger.info("GhostDisplays ready: ${displayRegistry.controllerCount()} controllers active.") logger.info("GhostDisplays ready: ${displayRegistry.controllerCount()} controllers active.")
server.scheduler.runTaskTimer(this, Runnable { server.globalRegionScheduler.runAtFixedRate(this, { _ ->
displayRegistry.tick() displayRegistry.updateAudiences()
}, 10L, 10L) }, 20L, 20L)
} }
override fun onDisable() { override fun onDisable() {
kommand?.unregister() kommand?.unregister()
kommand = null kommand = null
runCatching { editSessions.shutdown() }
runCatching { displayManager.destroyAll() } runCatching { displayManager.destroyAll() }
runCatching { serviceImpl.destroyAll() } runCatching { serviceImpl.destroyAll() }
permissionSession = null permissionSession = null
@@ -5,44 +5,36 @@ import net.hareworks.ghostdisplays.api.audience.AudiencePredicate
import net.hareworks.ghostdisplays.api.click.ClickPriority import net.hareworks.ghostdisplays.api.click.ClickPriority
import net.hareworks.ghostdisplays.api.click.DisplayClickHandler import net.hareworks.ghostdisplays.api.click.DisplayClickHandler
import net.hareworks.ghostdisplays.api.click.HandlerRegistration import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import org.bukkit.entity.Display import net.hareworks.ghostdisplays.internal.fake.FakeBlockDisplay
import org.bukkit.entity.Interaction import net.hareworks.ghostdisplays.internal.fake.FakeDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeItemDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeTextDisplay
import org.bukkit.Location
import org.bukkit.entity.Player import org.bukkit.entity.Player
import java.util.UUID import java.util.UUID
interface DisplayController<T : Display> { interface DisplayController {
val display: T val entityId: Int
val interaction: Interaction? val displayType: DisplayType
val location: Location
fun show(player: Player) fun show(player: Player)
fun hide(player: Player) fun hide(player: Player)
fun isViewing(playerId: UUID): Boolean fun isViewing(playerId: UUID): Boolean
fun viewerIds(): Set<UUID> fun viewerIds(): Set<UUID>
fun applyEntityUpdate(mutator: (T) -> Unit) fun updateText(mutator: (FakeTextDisplay) -> Unit)
fun updateBlock(mutator: (FakeBlockDisplay) -> Unit)
fun updateItem(mutator: (FakeItemDisplay) -> Unit)
fun updateDisplay(mutator: (FakeDisplay) -> Unit)
fun teleport(location: Location)
/**
* Displayの基本可視状態を設定します。
* ルールにマッチしなかった場合のデフォルトの振る舞いとなります。
*/
fun setBaseVisibility(visible: Boolean) fun setBaseVisibility(visible: Boolean)
/**
* 従来の簡易メソッド。Action=ADD として登録します。
*/
fun addAudience(predicate: AudiencePredicate): HandlerRegistration fun addAudience(predicate: AudiencePredicate): HandlerRegistration
/**
* ルールを追加します。評価順序は追加順です。
*/
fun addAudienceRule(predicate: AudiencePredicate, action: AudienceAction): HandlerRegistration fun addAudienceRule(predicate: AudiencePredicate, action: AudienceAction): HandlerRegistration
fun clearAudienceRules() fun clearAudienceRules()
fun refreshAudience(target: Player? = null) fun refreshAudience(target: Player? = null)
fun needsPeriodicUpdate(): Boolean = false
fun destroy() fun destroy()
@@ -1,30 +1,36 @@
package net.hareworks.ghostdisplays.api 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 import org.bukkit.Location
import org.bukkit.entity.BlockDisplay
import org.bukkit.entity.ItemDisplay
import org.bukkit.entity.TextDisplay
import org.bukkit.inventory.ItemStack import org.bukkit.inventory.ItemStack
interface DisplayService { interface DisplayService {
fun createTextDisplay( fun createTextDisplay(
location: Location, location: Location,
interaction: InteractionOptions = InteractionOptions.Disabled, interaction: InteractionOptions = InteractionOptions.Disabled,
builder: TextDisplay.() -> Unit = {} builder: FakeTextDisplay.() -> Unit = {}
): DisplayController<TextDisplay> ): DisplayController
fun createBlockDisplay( fun createBlockDisplay(
location: Location, location: Location,
interaction: InteractionOptions = InteractionOptions.Disabled, interaction: InteractionOptions = InteractionOptions.Disabled,
builder: BlockDisplay.() -> Unit = {} builder: FakeBlockDisplay.() -> Unit = {}
): DisplayController<BlockDisplay> ): DisplayController
fun createItemDisplay( fun createItemDisplay(
location: Location, location: Location,
itemStack: ItemStack, itemStack: ItemStack,
interaction: InteractionOptions = InteractionOptions.Disabled, interaction: InteractionOptions = InteractionOptions.Disabled,
builder: ItemDisplay.() -> Unit = {} builder: FakeItemDisplay.() -> Unit = {}
): DisplayController<ItemDisplay> ): DisplayController
fun createInteraction(
location: Location,
builder: FakeInteraction.() -> Unit = {}
): InteractionController
fun destroyAll() fun destroyAll()
} }
@@ -0,0 +1,7 @@
package net.hareworks.ghostdisplays.api
enum class DisplayType {
TEXT,
BLOCK,
ITEM
}
@@ -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()
}
@@ -7,4 +7,12 @@ import org.bukkit.entity.Player
*/ */
fun interface AudiencePredicate { fun interface AudiencePredicate {
fun test(player: Player): Boolean fun test(player: Player): Boolean
/**
* 表示対象判定が動的(時間経過や移動で変化する)かどうかを返します。
* trueの場合、定期的な再評価の対象となります。
*/
fun isDynamic(): Boolean {
return false
}
} }
@@ -28,12 +28,15 @@ object AudiencePredicates {
} }
fun near(location: Location, radius: Double): AudiencePredicate { fun near(location: Location, radius: Double): AudiencePredicate {
val radiusSq = radius * radius val radiusSq = radius * radius
val worldName = location.world?.name val worldName = location.world?.name
require(worldName != null) { "Location must have a world" } require(worldName != null) { "Location must have a world" }
return AudiencePredicate { player ->
player.world.name == worldName && player.location.distanceSquared(location) <= radiusSq return object : AudiencePredicate {
override fun test(player: Player): Boolean {
return player.world.name == worldName && player.location.distanceSquared(location) <= radiusSq
}
override fun isDynamic(): Boolean = true
} }
} }
} }
@@ -1,9 +1,7 @@
package net.hareworks.ghostdisplays.api.click package net.hareworks.ghostdisplays.api.click
import net.hareworks.ghostdisplays.api.DisplayController import net.hareworks.ghostdisplays.api.DisplayController
import org.bukkit.entity.Entity
import org.bukkit.entity.Player import org.bukkit.entity.Player
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.EquipmentSlot
enum class ClickPriority { enum class ClickPriority {
@@ -20,10 +18,10 @@ enum class ClickSurface {
data class DisplayClickContext( data class DisplayClickContext(
val player: Player, val player: Player,
val hand: EquipmentSlot?, val hand: EquipmentSlot?,
val clickedEntity: Entity, val entityId: Int,
val surface: ClickSurface, val surface: ClickSurface,
val controller: DisplayController<*>, val controller: DisplayController,
val event: PlayerInteractEntityEvent var cancelled: Boolean = false
) )
fun interface DisplayClickHandler { fun interface DisplayClickHandler {
@@ -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()
}
}
@@ -6,7 +6,6 @@ import java.util.UUID
import net.hareworks.ghostdisplays.display.DisplayManager import net.hareworks.ghostdisplays.display.DisplayManager
import net.hareworks.ghostdisplays.display.DisplayManager.DisplayOperationException import net.hareworks.ghostdisplays.display.DisplayManager.DisplayOperationException
import net.hareworks.ghostdisplays.display.DisplayKind import net.hareworks.ghostdisplays.display.DisplayKind
import net.hareworks.ghostdisplays.display.EditSessionManager
import net.hareworks.kommand_lib.KommandLib import net.hareworks.kommand_lib.KommandLib
import net.hareworks.kommand_lib.kommand import net.hareworks.kommand_lib.kommand
import net.hareworks.permits_lib.bukkit.MutationSession import net.hareworks.permits_lib.bukkit.MutationSession
@@ -24,7 +23,6 @@ object CommandRegistrar {
fun register( fun register(
plugin: JavaPlugin, plugin: JavaPlugin,
manager: DisplayManager, manager: DisplayManager,
editSessions: EditSessionManager,
permissionSession: MutationSession permissionSession: MutationSession
): KommandLib = ): KommandLib =
kommand(plugin) { kommand(plugin) {
@@ -54,8 +52,7 @@ object CommandRegistrar {
val location = player.anchorLocation() val location = player.anchorLocation()
try { try {
val display = manager.createTextDisplay(id, location, "", player) val display = manager.createTextDisplay(id, location, "", player)
editSessions.begin(player, display.id) sender.success("Text display '${display.id}' created at ${describe(location)}. Use /ghostdisplay text set ${display.id} <content> to set text.")
sender.success("Text display '${display.id}' created at ${describe(location)}. Type text in chat (or 'cancel') to set content.")
} catch (ex: DisplayOperationException) { } catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to create text display.") sender.failure(ex.message ?: "Failed to create text display.")
} }
@@ -222,27 +219,6 @@ object CommandRegistrar {
} }
literal("text") { 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")
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") { literal("set") {
string("id") { string("id") {
suggests { prefix -> suggests { prefix ->
@@ -258,7 +234,7 @@ object CommandRegistrar {
val raw = token.replace('_', ' ').replace("\\n", "\n") val raw = token.replace('_', ' ').replace("\\n", "\n")
try { try {
manager.updateText(id, raw) manager.updateText(id, raw)
sender.success("Updated text for '$id'. Use /ghostdisplay text edit $id for multi-line content.") sender.success("Updated text for '$id'.")
} catch (ex: DisplayOperationException) { } catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update text.") sender.failure(ex.message ?: "Failed to update text.")
} }
@@ -266,16 +242,6 @@ object CommandRegistrar {
} }
} }
} }
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("block") {
@@ -498,7 +464,7 @@ object CommandRegistrar {
val id = argument<String>("id") val id = argument<String>("id")
val targets = argument<List<Player>>("targets") val targets = argument<List<Player>>("targets")
var count = 0 var count = 0
targets.forEach { targets.forEach {
if (manager.removePlayerAudience(id, it.uniqueId)) count++ if (manager.removePlayerAudience(id, it.uniqueId)) count++
} }
sender.success("Removed $count/${targets.size} player(s) from audience of '$id'.") sender.success("Removed $count/${targets.size} player(s) from audience of '$id'.")
@@ -531,7 +497,6 @@ object CommandRegistrar {
private fun CommandSender.showUsage() { private fun CommandSender.showUsage() {
info("GhostDisplays commands:") info("GhostDisplays commands:")
info(" /ghostdisplay create <text|block|item> ...") info(" /ghostdisplay create <text|block|item> ...")
info(" /ghostdisplay text edit <id> - edit text via chat")
info(" /ghostdisplay viewer <add|remove|clear> ...") info(" /ghostdisplay viewer <add|remove|clear> ...")
info(" /ghostdisplay audience <permission|near|clear> ...") info(" /ghostdisplay audience <permission|near|clear> ...")
info(" /ghostdisplay list | info <id> | delete <id>") info(" /ghostdisplay list | info <id> | delete <id>")
@@ -8,18 +8,13 @@ import net.hareworks.ghostdisplays.api.DisplayService
import net.hareworks.ghostdisplays.api.InteractionOptions import net.hareworks.ghostdisplays.api.InteractionOptions
import net.hareworks.ghostdisplays.api.audience.AudienceAction import net.hareworks.ghostdisplays.api.audience.AudienceAction
import net.hareworks.ghostdisplays.api.audience.AudiencePredicates 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.Component
import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.minimessage.MiniMessage
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
import org.bukkit.Bukkit import org.bukkit.Bukkit
import org.bukkit.Location import org.bukkit.Location
import org.bukkit.block.data.BlockData 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.Player
import org.bukkit.entity.TextDisplay
import org.bukkit.inventory.ItemStack import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin import org.bukkit.plugin.java.JavaPlugin
@@ -28,7 +23,7 @@ class DisplayManager(
private val service: DisplayService, private val service: DisplayService,
private val miniMessage: MiniMessage private val miniMessage: MiniMessage
) { ) {
private val displays = ConcurrentHashMap<String, ManagedDisplay<*>>() private val displays = ConcurrentHashMap<String, ManagedDisplay>()
private val idPattern = Regex("^[a-z0-9_\\-]{1,32}$") private val idPattern = Regex("^[a-z0-9_\\-]{1,32}$")
private val logger: Logger = plugin.logger private val logger: Logger = plugin.logger
private val plainSerializer = PlainTextComponentSerializer.plainText() private val plainSerializer = PlainTextComponentSerializer.plainText()
@@ -38,10 +33,8 @@ class DisplayManager(
ensureIdAvailable(normalized) ensureIdAvailable(normalized)
val safeLocation = location.clone() val safeLocation = location.clone()
val controller = service.createTextDisplay(safeLocation, INTERACTION_DEFAULT) val controller = service.createTextDisplay(safeLocation, INTERACTION_DEFAULT)
val component = parseMiniMessage(initialContent.ifBlank { "<gray>${normalized}" }) val component = parseMiniMessage(initialContent.ifBlank { "<gray>${normalized}" }, creator)
controller.applyEntityUpdate { textDisplay -> controller.updateText { it.text = component }
textDisplay.text(component)
}
val managed = ManagedDisplay.Text( val managed = ManagedDisplay.Text(
id = normalized, id = normalized,
controller = controller, controller = controller,
@@ -61,7 +54,7 @@ class DisplayManager(
ensureIdAvailable(normalized) ensureIdAvailable(normalized)
val safeLocation = location.clone() val safeLocation = location.clone()
val controller = service.createBlockDisplay(safeLocation, INTERACTION_DEFAULT) val controller = service.createBlockDisplay(safeLocation, INTERACTION_DEFAULT)
controller.applyEntityUpdate { it.block = blockData.clone() } controller.updateBlock { it.blockData = blockData.clone() }
val managed = ManagedDisplay.Block( val managed = ManagedDisplay.Block(
id = normalized, id = normalized,
controller = controller, controller = controller,
@@ -80,7 +73,6 @@ class DisplayManager(
ensureIdAvailable(normalized) ensureIdAvailable(normalized)
val safeLocation = location.clone() val safeLocation = location.clone()
val controller = service.createItemDisplay(safeLocation, itemStack.clone(), INTERACTION_DEFAULT) val controller = service.createItemDisplay(safeLocation, itemStack.clone(), INTERACTION_DEFAULT)
controller.applyEntityUpdate { it.setItemStack(itemStack.clone()) }
val managed = ManagedDisplay.Item( val managed = ManagedDisplay.Item(
id = normalized, id = normalized,
controller = controller, controller = controller,
@@ -94,14 +86,14 @@ class DisplayManager(
return managed return managed
} }
fun listDisplays(): List<ManagedDisplay<*>> = displays.values.sortedBy { it.id } fun listDisplays(): List<ManagedDisplay> = displays.values.sortedBy { it.id }
fun findDisplay(id: String): ManagedDisplay<*>? = displays[normalizeId(id)] fun findDisplay(id: String): ManagedDisplay? = displays[normalizeId(id)]
fun updateText(id: String, content: String): Component { fun updateText(id: String, content: String, playerContext: Player? = null): Component {
val display = requireText(id) val display = requireText(id)
val component = parseMiniMessage(content) val component = parseMiniMessage(content, playerContext)
display.controller.applyEntityUpdate { it.text(component) } display.controller.updateText { it.text = component }
display.rawContent = content display.rawContent = content
display.component = component display.component = component
return component return component
@@ -109,14 +101,14 @@ class DisplayManager(
fun updateBlock(id: String, blockData: BlockData) { fun updateBlock(id: String, blockData: BlockData) {
val display = requireBlock(id) val display = requireBlock(id)
display.controller.applyEntityUpdate { it.block = blockData.clone() } display.controller.updateBlock { it.blockData = blockData.clone() }
display.blockData = blockData.clone() display.blockData = blockData.clone()
} }
fun updateItem(id: String, itemStack: ItemStack) { fun updateItem(id: String, itemStack: ItemStack) {
val display = requireItem(id) val display = requireItem(id)
val clone = itemStack.clone() val clone = itemStack.clone()
display.controller.applyEntityUpdate { it.setItemStack(clone) } display.controller.updateItem { it.itemStack = clone }
display.itemStack = clone display.itemStack = clone
} }
@@ -219,14 +211,14 @@ class DisplayManager(
displays.clear() displays.clear()
} }
fun describeContent(display: ManagedDisplay<*>): String = fun describeContent(display: ManagedDisplay): String =
when (display) { when (display) {
is ManagedDisplay.Text -> plainSerializer.serialize(display.component) is ManagedDisplay.Text -> plainSerializer.serialize(display.component)
is ManagedDisplay.Block -> display.blockData.asString is ManagedDisplay.Block -> display.blockData.asString
is ManagedDisplay.Item -> display.itemStack.type.name.lowercase() is ManagedDisplay.Item -> display.itemStack.type.name.lowercase()
} }
private fun requireDisplay(id: String): ManagedDisplay<*> = private fun requireDisplay(id: String): ManagedDisplay =
displays[normalizeId(id)] ?: throw DisplayOperationException("Display '$id' does not exist.") displays[normalizeId(id)] ?: throw DisplayOperationException("Display '$id' does not exist.")
private fun requireText(id: String): ManagedDisplay.Text = private fun requireText(id: String): ManagedDisplay.Text =
@@ -253,11 +245,18 @@ class DisplayManager(
} }
} }
private fun parseMiniMessage(raw: String): Component = private fun parseMiniMessage(raw: String, playerContext: Player? = null): Component {
runCatching { miniMessage.deserialize(if (raw.isBlank()) "<gray>empty" else raw) } val rawOrEmpty = if (raw.isBlank()) "<gray>empty" else raw
val withPlaceholders = if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
me.clip.placeholderapi.PlaceholderAPI.setPlaceholders(playerContext, rawOrEmpty)
} else {
rawOrEmpty
}
return runCatching { miniMessage.deserialize(withPlaceholders) }
.getOrElse { ex -> .getOrElse { ex ->
throw DisplayOperationException("MiniMessage parse error: ${ex.message ?: ex.javaClass.simpleName}") throw DisplayOperationException("MiniMessage parse error: ${ex.message ?: ex.javaClass.simpleName}")
} }
}
companion object { companion object {
private val INTERACTION_DEFAULT = InteractionOptions.enabled(width = 0.8, height = 0.8) private val INTERACTION_DEFAULT = InteractionOptions.enabled(width = 0.8, height = 0.8)
@@ -1,57 +0,0 @@
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.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: io.papermc.paper.event.player.AsyncChatEvent) {
val displayId = sessions[event.player.uniqueId] ?: return
event.isCancelled = true
val serializer = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
val message = serializer.serialize(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, Runnable {
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)
}
}
@@ -8,10 +8,6 @@ import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import net.kyori.adventure.text.Component import net.kyori.adventure.text.Component
import org.bukkit.Location import org.bukkit.Location
import org.bukkit.block.data.BlockData 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 import org.bukkit.inventory.ItemStack
enum class DisplayKind { enum class DisplayKind {
@@ -30,10 +26,10 @@ data class AudienceBinding(
} }
} }
sealed class ManagedDisplay<T : Display>( sealed class ManagedDisplay(
val id: String, val id: String,
val kind: DisplayKind, val kind: DisplayKind,
val controller: DisplayController<T>, val controller: DisplayController,
location: Location, location: Location,
val createdAt: Instant, val createdAt: Instant,
val createdBy: UUID? val createdBy: UUID?
@@ -61,29 +57,29 @@ sealed class ManagedDisplay<T : Display>(
class Text( class Text(
id: String, id: String,
controller: DisplayController<TextDisplay>, controller: DisplayController,
location: Location, location: Location,
createdAt: Instant, createdAt: Instant,
createdBy: UUID?, createdBy: UUID?,
var rawContent: String, var rawContent: String,
var component: Component var component: Component
) : ManagedDisplay<TextDisplay>(id, DisplayKind.TEXT, controller, location, createdAt, createdBy) ) : ManagedDisplay(id, DisplayKind.TEXT, controller, location, createdAt, createdBy)
class Block( class Block(
id: String, id: String,
controller: DisplayController<BlockDisplay>, controller: DisplayController,
location: Location, location: Location,
createdAt: Instant, createdAt: Instant,
createdBy: UUID?, createdBy: UUID?,
var blockData: BlockData var blockData: BlockData
) : ManagedDisplay<BlockDisplay>(id, DisplayKind.BLOCK, controller, location, createdAt, createdBy) ) : ManagedDisplay(id, DisplayKind.BLOCK, controller, location, createdAt, createdBy)
class Item( class Item(
id: String, id: String,
controller: DisplayController<ItemDisplay>, controller: DisplayController,
location: Location, location: Location,
createdAt: Instant, createdAt: Instant,
createdBy: UUID?, createdBy: UUID?,
var itemStack: ItemStack var itemStack: ItemStack
) : ManagedDisplay<ItemDisplay>(id, DisplayKind.ITEM, controller, location, createdAt, createdBy) ) : ManagedDisplay(id, DisplayKind.ITEM, controller, location, createdAt, createdBy)
} }
@@ -1,20 +1,20 @@
package net.hareworks.ghostdisplays.internal package net.hareworks.ghostdisplays.internal
import java.util.concurrent.CompletableFuture import java.util.UUID
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CopyOnWriteArraySet
import net.hareworks.ghostdisplays.api.DisplayController import net.hareworks.ghostdisplays.api.DisplayController
import net.hareworks.ghostdisplays.api.DisplayService import net.hareworks.ghostdisplays.api.DisplayService
import net.hareworks.ghostdisplays.api.InteractionController
import net.hareworks.ghostdisplays.api.InteractionOptions import net.hareworks.ghostdisplays.api.InteractionOptions
import net.hareworks.ghostdisplays.internal.controller.BaseDisplayController 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.controller.DisplayRegistry
import org.bukkit.Bukkit 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 net.hareworks.ghostdisplays.internal.nms.DisplayPacketFactory
import org.bukkit.Location 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.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin import org.bukkit.plugin.java.JavaPlugin
@@ -22,80 +22,95 @@ internal class DefaultDisplayService(
private val plugin: JavaPlugin, private val plugin: JavaPlugin,
private val registry: DisplayRegistry private val registry: DisplayRegistry
) : DisplayService { ) : DisplayService {
private val controllers = CopyOnWriteArraySet<BaseDisplayController<out Display>>() private val controllers = CopyOnWriteArraySet<BaseDisplayController>()
private val interactionControllers = CopyOnWriteArraySet<BaseInteractionController>()
override fun createTextDisplay( override fun createTextDisplay(
location: Location, location: Location,
interaction: InteractionOptions, interaction: InteractionOptions,
builder: TextDisplay.() -> Unit builder: FakeTextDisplay.() -> Unit
): DisplayController<TextDisplay> = spawnDisplay(location, TextDisplay::class.java, interaction) { ): DisplayController {
builder(it) val fake = FakeTextDisplay(
entityId = DisplayPacketFactory.nextEntityId(),
uuid = UUID.randomUUID(),
location = location.clone()
)
builder(fake)
return register(fake, location, interaction)
} }
override fun createBlockDisplay( override fun createBlockDisplay(
location: Location, location: Location,
interaction: InteractionOptions, interaction: InteractionOptions,
builder: BlockDisplay.() -> Unit builder: FakeBlockDisplay.() -> Unit
): DisplayController<BlockDisplay> = spawnDisplay(location, BlockDisplay::class.java, interaction) { ): DisplayController {
builder(it) val fake = FakeBlockDisplay(
entityId = DisplayPacketFactory.nextEntityId(),
uuid = UUID.randomUUID(),
location = location.clone()
)
builder(fake)
return register(fake, location, interaction)
} }
override fun createItemDisplay( override fun createItemDisplay(
location: Location, location: Location,
itemStack: ItemStack, itemStack: ItemStack,
interaction: InteractionOptions, interaction: InteractionOptions,
builder: ItemDisplay.() -> Unit builder: FakeItemDisplay.() -> Unit
): DisplayController<ItemDisplay> = spawnDisplay(location, ItemDisplay::class.java, interaction) { ): DisplayController {
it.setItemStack(itemStack.clone()) val fake = FakeItemDisplay(
builder(it) entityId = DisplayPacketFactory.nextEntityId(),
uuid = UUID.randomUUID(),
location = location.clone()
)
fake.itemStack = itemStack.clone()
builder(fake)
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() { override fun destroyAll() {
controllers.forEach { it.destroy() } controllers.forEach { it.destroy() }
controllers.clear() controllers.clear()
interactionControllers.forEach { it.destroy() }
interactionControllers.clear()
} }
private fun <T : Display> spawnDisplay( private fun register(
fake: net.hareworks.ghostdisplays.internal.fake.FakeDisplay,
location: Location, location: Location,
type: Class<T>, interactionOptions: InteractionOptions
interactionOptions: InteractionOptions, ): DisplayController {
afterSpawn: (T) -> Unit val fakeInteraction = if (interactionOptions.enabled) {
): DisplayController<T> { FakeInteraction(
val entity = callSync { entityId = DisplayPacketFactory.nextEntityId(),
val world = location.world ?: throw IllegalArgumentException("Location must have a world") uuid = UUID.randomUUID(),
world.spawn(location, type) { spawned -> location = location.clone()
spawned.setPersistent(false) ).apply {
spawned.setVisibleByDefault(false) width = interactionOptions.effectiveWidth().toFloat()
height = interactionOptions.effectiveHeight().toFloat()
responsive = interactionOptions.responsive
} }
} } else null
callSync { afterSpawn(entity) } val controller = BaseDisplayController(plugin, fake, fakeInteraction, registry)
val interaction = if (interactionOptions.enabled) spawnInteraction(location, interactionOptions) else null
val controller = BaseDisplayController(plugin, entity, interaction, registry)
controllers += controller controllers += controller
registry.register(controller) registry.register(controller)
return 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, Runnable { future.complete(action()) })
future.join()
}
}
} }
@@ -2,11 +2,11 @@ package net.hareworks.ghostdisplays.internal.controller
import java.util.HashSet import java.util.HashSet
import java.util.UUID import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import net.hareworks.ghostdisplays.api.DisplayController import net.hareworks.ghostdisplays.api.DisplayController
import net.hareworks.ghostdisplays.api.DisplayType
import net.hareworks.ghostdisplays.api.audience.AudienceAction import net.hareworks.ghostdisplays.api.audience.AudienceAction
import net.hareworks.ghostdisplays.api.audience.AudiencePredicate import net.hareworks.ghostdisplays.api.audience.AudiencePredicate
import net.hareworks.ghostdisplays.api.click.ClickPriority import net.hareworks.ghostdisplays.api.click.ClickPriority
@@ -14,51 +14,69 @@ import net.hareworks.ghostdisplays.api.click.ClickSurface
import net.hareworks.ghostdisplays.api.click.DisplayClickContext import net.hareworks.ghostdisplays.api.click.DisplayClickContext
import net.hareworks.ghostdisplays.api.click.DisplayClickHandler import net.hareworks.ghostdisplays.api.click.DisplayClickHandler
import net.hareworks.ghostdisplays.api.click.HandlerRegistration import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import net.hareworks.ghostdisplays.internal.fake.FakeBlockDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeInteraction
import net.hareworks.ghostdisplays.internal.fake.FakeItemDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeTextDisplay
import net.hareworks.ghostdisplays.internal.nms.DisplayPacketFactory
import org.bukkit.Bukkit import org.bukkit.Bukkit
import org.bukkit.entity.Display import org.bukkit.Location
import org.bukkit.entity.Entity
import org.bukkit.entity.Interaction
import org.bukkit.entity.Player import org.bukkit.entity.Player
import org.bukkit.event.player.PlayerInteractEntityEvent import org.bukkit.inventory.EquipmentSlot
import org.bukkit.plugin.java.JavaPlugin import org.bukkit.plugin.java.JavaPlugin
internal class BaseDisplayController<T : Display>( internal class BaseDisplayController(
private val plugin: JavaPlugin, private val plugin: JavaPlugin,
override val display: T, val fakeDisplay: FakeDisplay,
override val interaction: Interaction?, val fakeInteraction: FakeInteraction?,
private val registry: DisplayRegistry private val registry: DisplayRegistry
) : DisplayController<T> { ) : DisplayController {
private val destroyed = AtomicBoolean(false) private val destroyed = AtomicBoolean(false)
private val viewerCounts = ConcurrentHashMap<UUID, Int>() private val viewerCounts = ConcurrentHashMap<UUID, Int>()
private val handlers = CopyOnWriteArrayList<HandlerEntry>() private val handlers = CopyOnWriteArrayList<HandlerEntry>()
// Audience Management
private var baseVisibility: Boolean = false private var baseVisibility: Boolean = false
private val audienceRules = CopyOnWriteArrayList<RuleEntry>() private val audienceRules = CopyOnWriteArrayList<RuleEntry>()
private val autoVisiblePlayers = ConcurrentHashMap.newKeySet<UUID>() private val autoVisiblePlayers = ConcurrentHashMap.newKeySet<UUID>()
private var hasDynamicRules: Boolean = false
override val entityId: Int get() = fakeDisplay.entityId
override val displayType: DisplayType
get() = when (fakeDisplay) {
is FakeTextDisplay -> DisplayType.TEXT
is FakeBlockDisplay -> DisplayType.BLOCK
is FakeItemDisplay -> DisplayType.ITEM
else -> DisplayType.BLOCK
}
override val location: Location get() = fakeDisplay.location.clone()
override fun needsPeriodicUpdate(): Boolean = hasDynamicRules
override fun show(player: Player) { override fun show(player: Player) {
runSync { val uuid = player.uniqueId
val uuid = player.uniqueId val newCount = viewerCounts.compute(uuid) { _, count -> (count ?: 0) + 1 } ?: 1
val newCount = viewerCounts.compute(uuid) { _, count -> (count ?: 0) + 1 } ?: 1 if (newCount == 1) {
if (newCount == 1) { DisplayPacketFactory.sendPacket(player, DisplayPacketFactory.createSpawnBundle(fakeDisplay))
player.showEntity(plugin, display) fakeInteraction?.let {
interaction?.let { player.showEntity(plugin, it) } DisplayPacketFactory.sendPacket(player, DisplayPacketFactory.createSpawnBundle(it))
} }
} }
} }
override fun hide(player: Player) { override fun hide(player: Player) {
runSync { val uuid = player.uniqueId
val uuid = player.uniqueId val current = viewerCounts[uuid] ?: return
val current = viewerCounts[uuid] ?: return@runSync if (current <= 1) {
if (current <= 1) { viewerCounts.remove(uuid)
viewerCounts.remove(uuid) val ids = mutableListOf(fakeDisplay.entityId)
player.hideEntity(plugin, display) fakeInteraction?.let { ids.add(it.entityId) }
interaction?.let { player.hideEntity(plugin, it) } DisplayPacketFactory.sendPacket(player, DisplayPacketFactory.createRemovePacket(*ids.toIntArray()))
} else { } else {
viewerCounts[uuid] = current - 1 viewerCounts[uuid] = current - 1
}
} }
} }
@@ -66,25 +84,53 @@ internal class BaseDisplayController<T : Display>(
override fun viewerIds(): Set<UUID> = HashSet(viewerCounts.keys) override fun viewerIds(): Set<UUID> = HashSet(viewerCounts.keys)
override fun applyEntityUpdate(mutator: (T) -> Unit) { override fun updateText(mutator: (FakeTextDisplay) -> Unit) {
callSync { check(fakeDisplay is FakeTextDisplay) { "Not a text display" }
mutator(display) mutator(fakeDisplay as FakeTextDisplay)
broadcastMetadata()
}
override fun updateBlock(mutator: (FakeBlockDisplay) -> Unit) {
check(fakeDisplay is FakeBlockDisplay) { "Not a block display" }
mutator(fakeDisplay as FakeBlockDisplay)
broadcastMetadata()
}
override fun updateItem(mutator: (FakeItemDisplay) -> Unit) {
check(fakeDisplay is FakeItemDisplay) { "Not an item display" }
mutator(fakeDisplay as FakeItemDisplay)
broadcastMetadata()
}
override fun updateDisplay(mutator: (FakeDisplay) -> Unit) {
mutator(fakeDisplay)
broadcastMetadata()
}
override fun teleport(location: Location) {
fakeDisplay.location = location.clone()
fakeInteraction?.let { it.location = location.clone() }
val viewers = onlineViewers()
if (viewers.isEmpty()) return
val displayPacket = DisplayPacketFactory.createTeleportPacket(fakeDisplay)
viewers.forEach { DisplayPacketFactory.sendPacket(it, displayPacket) }
fakeInteraction?.let { interaction ->
val interactionPacket = DisplayPacketFactory.createTeleportPacket(interaction)
viewers.forEach { DisplayPacketFactory.sendPacket(it, interactionPacket) }
} }
} }
override fun destroy() { override fun destroy() {
if (!destroyed.compareAndSet(false, true)) return if (!destroyed.compareAndSet(false, true)) return
registry.unregister(this) registry.unregister(this)
val viewers = viewerIds().mapNotNull { Bukkit.getPlayer(it) } val viewers = onlineViewers()
runSync { if (viewers.isNotEmpty()) {
viewers.forEach { val ids = mutableListOf(fakeDisplay.entityId)
it.hideEntity(plugin, display) fakeInteraction?.let { ids.add(it.entityId) }
interaction?.let { interactionEntity -> it.hideEntity(plugin, interactionEntity) } val removePacket = DisplayPacketFactory.createRemovePacket(*ids.toIntArray())
} viewers.forEach { DisplayPacketFactory.sendPacket(it, removePacket) }
display.remove()
interaction?.remove()
viewerCounts.clear()
} }
viewerCounts.clear()
handlers.clear() handlers.clear()
audienceRules.clear() audienceRules.clear()
autoVisiblePlayers.clear() autoVisiblePlayers.clear()
@@ -93,9 +139,7 @@ internal class BaseDisplayController<T : Display>(
override fun onClick(priority: ClickPriority, handler: DisplayClickHandler): HandlerRegistration { override fun onClick(priority: ClickPriority, handler: DisplayClickHandler): HandlerRegistration {
val entry = HandlerEntry(priority, handler) val entry = HandlerEntry(priority, handler)
handlers += entry handlers += entry
return HandlerRegistration { return HandlerRegistration { handlers.remove(entry) }
handlers.remove(entry)
}
} }
override fun setBaseVisibility(visible: Boolean) { override fun setBaseVisibility(visible: Boolean) {
@@ -110,9 +154,11 @@ internal class BaseDisplayController<T : Display>(
override fun addAudienceRule(predicate: AudiencePredicate, action: AudienceAction): HandlerRegistration { override fun addAudienceRule(predicate: AudiencePredicate, action: AudienceAction): HandlerRegistration {
val entry = RuleEntry(predicate, action) val entry = RuleEntry(predicate, action)
audienceRules.add(entry) audienceRules.add(entry)
updateDynamicStatus()
refreshAudience() refreshAudience()
return HandlerRegistration { return HandlerRegistration {
audienceRules.remove(entry) audienceRules.remove(entry)
updateDynamicStatus()
refreshAudience() refreshAudience()
} }
} }
@@ -123,30 +169,60 @@ internal class BaseDisplayController<T : Display>(
} }
override fun refreshAudience(target: Player?) { override fun refreshAudience(target: Player?) {
refreshAudienceInternal(target) val players = target?.let { listOf(it) } ?: Bukkit.getOnlinePlayers()
} if (players.isEmpty()) return
private fun refreshAudienceInternal(target: Player? = null) { players.forEach { player ->
runSync { val uuid = player.uniqueId
val players = target?.let { listOf(it) } ?: Bukkit.getOnlinePlayers() val shouldBeVisible = evaluateVisibility(player)
if (players.isEmpty()) return@runSync val isCurrentlyAutoVisible = autoVisiblePlayers.contains(uuid)
players.forEach { player -> if (shouldBeVisible && !isCurrentlyAutoVisible) {
val uuid = player.uniqueId autoVisiblePlayers.add(uuid)
val shouldBeVisible = evaluateVisibility(player) show(player)
val isCurrentlyAutoVisible = autoVisiblePlayers.contains(uuid) } else if (!shouldBeVisible && isCurrentlyAutoVisible) {
autoVisiblePlayers.remove(uuid)
if (shouldBeVisible && !isCurrentlyAutoVisible) { hide(player)
autoVisiblePlayers.add(uuid)
show(player)
} else if (!shouldBeVisible && isCurrentlyAutoVisible) {
autoVisiblePlayers.remove(uuid)
hide(player)
}
} }
} }
} }
internal fun handleClick(player: Player, clickedEntityId: Int, surface: ClickSurface) {
val sortedHandlers = handlers.sortedBy { it.priority.ordinal }
if (sortedHandlers.isEmpty()) return
val context = DisplayClickContext(
player = player,
hand = EquipmentSlot.HAND,
entityId = clickedEntityId,
surface = surface,
controller = this
)
sortedHandlers.forEach { entry ->
entry.handler.handle(context)
if (context.cancelled) return
}
}
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(fakeDisplay)
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 { private fun evaluateVisibility(player: Player): Boolean {
var visible = baseVisibility var visible = baseVisibility
for (rule in audienceRules) { for (rule in audienceRules) {
@@ -161,50 +237,6 @@ internal class BaseDisplayController<T : Display>(
return visible return visible
} }
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)
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, Runnable { action() })
}
}
private fun <R> callSync(action: () -> R): R {
return if (Bukkit.isPrimaryThread()) {
action()
} else {
val future = CompletableFuture<R>()
Bukkit.getScheduler().runTask(plugin, Runnable { future.complete(action()) })
future.join()
}
}
private data class RuleEntry( private data class RuleEntry(
val predicate: AudiencePredicate, val predicate: AudiencePredicate,
val action: AudienceAction val action: AudienceAction
@@ -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
)
}
@@ -1,56 +1,93 @@
package net.hareworks.ghostdisplays.internal.controller package net.hareworks.ghostdisplays.internal.controller
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import net.hareworks.ghostdisplays.api.click.ClickSurface import net.hareworks.ghostdisplays.api.click.ClickSurface
import org.bukkit.entity.Display import net.hareworks.ghostdisplays.api.event.FakeInteractionClickEvent
import org.bukkit.entity.Entity import net.hareworks.ghostdisplays.internal.nms.InteractAction
import org.bukkit.entity.Interaction import net.hareworks.ghostdisplays.internal.nms.InteractionPacketListener
import org.bukkit.Bukkit
import org.bukkit.entity.Player import org.bukkit.entity.Player
import org.bukkit.event.EventHandler import org.bukkit.event.EventHandler
import org.bukkit.event.Listener import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerChangedWorldEvent import org.bukkit.event.player.PlayerChangedWorldEvent
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.event.player.PlayerJoinEvent import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerQuitEvent import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.event.player.PlayerRespawnEvent import org.bukkit.event.player.PlayerRespawnEvent
import org.bukkit.plugin.java.JavaPlugin
internal class DisplayRegistry : Listener { internal class DisplayRegistry : Listener {
private val displayControllers = ConcurrentHashMap<UUID, BaseDisplayController<out Display>>() private val displayControllers = ConcurrentHashMap<Int, BaseDisplayController>()
private val interactionControllers = ConcurrentHashMap<UUID, BaseDisplayController<out Display>>() private val displayInteractionControllers = ConcurrentHashMap<Int, BaseDisplayController>()
private val standaloneInteractionControllers = ConcurrentHashMap<Int, BaseInteractionController>()
private lateinit var packetListener: InteractionPacketListener
fun register(controller: BaseDisplayController<out Display>) { fun initialize(plugin: JavaPlugin) {
displayControllers[controller.display.uniqueId] = controller packetListener = InteractionPacketListener(plugin) { player, data ->
controller.interaction?.let { // スタンドアロンInteractionを先にチェック → Bukkitイベント発火
interactionControllers[it.uniqueId] = controller 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, data.entityId, surface)
}
}
packetListener.injectAll()
plugin.server.pluginManager.registerEvents(packetListener, plugin)
}
fun register(controller: BaseDisplayController) {
displayControllers[controller.fakeDisplay.entityId] = controller
controller.fakeInteraction?.let {
displayInteractionControllers[it.entityId] = controller
} }
} }
fun unregister(controller: BaseDisplayController<out Display>) { fun unregister(controller: BaseDisplayController) {
displayControllers.remove(controller.display.uniqueId, controller) displayControllers.remove(controller.fakeDisplay.entityId, controller)
controller.interaction?.let { controller.fakeInteraction?.let {
interactionControllers.remove(it.uniqueId, 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() { fun shutdown() {
displayControllers.clear() displayControllers.clear()
interactionControllers.clear() displayInteractionControllers.clear()
} standaloneInteractionControllers.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 @EventHandler
fun onPlayerQuit(event: PlayerQuitEvent) { fun onPlayerQuit(event: PlayerQuitEvent) {
controllersSnapshot().forEach { it.handlePlayerQuit(event.player) } displayControllersSnapshot().forEach { it.handlePlayerQuit(event.player) }
standaloneInteractionsSnapshot().forEach { it.handlePlayerQuit(event.player) }
} }
@EventHandler @EventHandler
@@ -68,32 +105,28 @@ internal class DisplayRegistry : Listener {
refreshAudiences(event.player) refreshAudiences(event.player)
} }
private fun lookupController(entity: Entity): BaseDisplayController<out Display>? { private fun displayControllersSnapshot(): Collection<BaseDisplayController> =
return displayControllers[entity.uniqueId]
?: interactionControllers[entity.uniqueId]
}
private fun controllersSnapshot(): Collection<BaseDisplayController<out Display>> =
displayControllers.values.toSet() displayControllers.values.toSet()
private fun standaloneInteractionsSnapshot(): Collection<BaseInteractionController> =
standaloneInteractionControllers.values.toSet()
private fun refreshAudiences(player: Player) { private fun refreshAudiences(player: Player) {
val controllers = controllersSnapshot() displayControllersSnapshot().forEach { it.refreshAudience(player) }
if (controllers.isEmpty()) return standaloneInteractionsSnapshot().forEach { it.refreshAudience(player) }
controllers.forEach { it.refreshAudience(player) }
} }
fun tick() { fun updateAudiences() {
val players = org.bukkit.Bukkit.getOnlinePlayers() val players = Bukkit.getOnlinePlayers()
if (players.isEmpty()) return if (players.isEmpty()) return
val controllers = controllersSnapshot()
if (controllers.isEmpty()) return val activeDisplays = displayControllersSnapshot().filter { it.needsPeriodicUpdate() }
val activeInteractions = standaloneInteractionsSnapshot().filter { it.needsPeriodicUpdate() }
// 最適化: 動的な評価が必要なコントローラーだけ抽出できれば良いが、 if (activeDisplays.isEmpty() && activeInteractions.isEmpty()) return
// 現状はシンプルに全走査する。
players.forEach { player -> players.forEach { player ->
controllers.forEach { controller -> activeDisplays.forEach { it.refreshAudience(player) }
controller.refreshAudience(player) activeInteractions.forEach { it.refreshAudience(player) }
}
} }
} }
} }
@@ -0,0 +1,15 @@
package net.hareworks.ghostdisplays.internal.fake
import java.util.UUID
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.block.data.BlockData
class FakeBlockDisplay(
entityId: Int,
uuid: UUID,
location: Location
) : FakeDisplay(entityId, uuid, location) {
var blockData: BlockData = Bukkit.createBlockData("minecraft:stone")
}
@@ -0,0 +1,42 @@
package net.hareworks.ghostdisplays.internal.fake
import java.util.UUID
import org.bukkit.Location
import org.bukkit.entity.Display.Billboard
import org.bukkit.entity.Display.Brightness
import org.bukkit.util.Transformation
import org.joml.Quaternionf
import org.joml.Vector3f
open class FakeDisplay(
entityId: Int,
uuid: UUID,
location: Location
) : FakeEntity(entityId, uuid, location) {
var translation: Vector3f = Vector3f(0f, 0f, 0f)
var scale: Vector3f = Vector3f(1f, 1f, 1f)
var leftRotation: Quaternionf = Quaternionf()
var rightRotation: Quaternionf = Quaternionf()
var transformation: Transformation
get() = Transformation(translation, leftRotation, scale, rightRotation)
set(value) {
translation = value.translation
leftRotation = value.leftRotation
scale = value.scale
rightRotation = value.rightRotation
}
var interpolationDelay: Int = 0
var interpolationDuration: Int = 0
var teleportInterpolationDuration: Int = 0
var billboard: Billboard = Billboard.FIXED
var brightness: Brightness? = null
var viewRange: Float = 1.0f
var shadowRadius: Float = 0.0f
var shadowStrength: Float = 1.0f
var displayWidth: Float = 0.0f
var displayHeight: Float = 0.0f
var glowColorOverride: Int = -1
}
@@ -0,0 +1,12 @@
package net.hareworks.ghostdisplays.internal.fake
import java.util.UUID
import org.bukkit.Location
open class FakeEntity(
val entityId: Int,
val uuid: UUID,
var location: Location
) {
var isGlowing: Boolean = false
}
@@ -0,0 +1,16 @@
package net.hareworks.ghostdisplays.internal.fake
import java.util.UUID
import org.bukkit.Location
class FakeInteraction(
entityId: Int,
uuid: UUID,
location: Location
) : FakeEntity(entityId, uuid, location) {
var key: String? = null
var width: Float = 0.8f
var height: Float = 0.8f
var responsive: Boolean = true
}
@@ -0,0 +1,16 @@
package net.hareworks.ghostdisplays.internal.fake
import java.util.UUID
import org.bukkit.Location
import org.bukkit.entity.ItemDisplay.ItemDisplayTransform
import org.bukkit.inventory.ItemStack
class FakeItemDisplay(
entityId: Int,
uuid: UUID,
location: Location
) : FakeDisplay(entityId, uuid, location) {
var itemStack: ItemStack = ItemStack.empty()
var itemDisplayTransform: ItemDisplayTransform = ItemDisplayTransform.NONE
}
@@ -0,0 +1,26 @@
package net.hareworks.ghostdisplays.internal.fake
import java.util.UUID
import net.kyori.adventure.text.Component
import org.bukkit.Location
class FakeTextDisplay(
entityId: Int,
uuid: UUID,
location: Location
) : FakeDisplay(entityId, uuid, location) {
var text: Component = Component.empty()
var lineWidth: Int = 200
var backgroundColor: Int = 0x40000000
var textOpacity: Byte = -1
var styleFlags: Byte = 0
companion object {
const val FLAG_SHADOW: Byte = 0x01
const val FLAG_SEE_THROUGH: Byte = 0x02
const val FLAG_DEFAULT_BACKGROUND: Byte = 0x04
const val FLAG_ALIGN_LEFT: Byte = 0x08
const val FLAG_ALIGN_RIGHT: Byte = 0x10
}
}
@@ -0,0 +1,184 @@
package net.hareworks.ghostdisplays.internal.nms
import io.papermc.paper.adventure.PaperAdventure
import net.hareworks.ghostdisplays.internal.fake.FakeBlockDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeEntity
import net.hareworks.ghostdisplays.internal.fake.FakeInteraction
import net.hareworks.ghostdisplays.internal.fake.FakeItemDisplay
import net.hareworks.ghostdisplays.internal.fake.FakeTextDisplay
import net.minecraft.network.protocol.Packet
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket
import net.minecraft.network.protocol.game.ClientboundBundlePacket
import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket
import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket
import net.minecraft.network.syncher.SynchedEntityData
import net.minecraft.world.entity.Entity
import net.minecraft.world.entity.EntityType
import net.minecraft.world.entity.PositionMoveRotation
import net.minecraft.world.phys.Vec3
import org.bukkit.craftbukkit.block.data.CraftBlockData
import org.bukkit.craftbukkit.entity.CraftPlayer
import org.bukkit.craftbukkit.inventory.CraftItemStack
import org.bukkit.entity.Display.Billboard
import org.bukkit.entity.Player
internal object DisplayPacketFactory {
fun nextEntityId(): Int = Entity.nextEntityId()
// --- Spawn packets ---
fun createSpawnPacket(fake: FakeDisplay): ClientboundAddEntityPacket {
val entityType = when (fake) {
is FakeTextDisplay -> EntityType.TEXT_DISPLAY
is FakeBlockDisplay -> EntityType.BLOCK_DISPLAY
is FakeItemDisplay -> EntityType.ITEM_DISPLAY
else -> EntityType.BLOCK_DISPLAY
}
return ClientboundAddEntityPacket(
fake.entityId,
fake.uuid,
fake.location.x, fake.location.y, fake.location.z,
fake.location.pitch, fake.location.yaw,
entityType,
0,
Vec3.ZERO,
0.0
)
}
fun createSpawnPacket(fake: FakeInteraction): ClientboundAddEntityPacket {
return ClientboundAddEntityPacket(
fake.entityId,
fake.uuid,
fake.location.x, fake.location.y, fake.location.z,
0f, 0f,
EntityType.INTERACTION,
0,
Vec3.ZERO,
0.0
)
}
// --- Metadata packets ---
fun createMetadataPacket(fake: FakeDisplay): ClientboundSetEntityDataPacket {
val values = buildDisplayMetadata(fake)
when (fake) {
is FakeTextDisplay -> values.addAll(buildTextMetadata(fake))
is FakeBlockDisplay -> values.addAll(buildBlockMetadata(fake))
is FakeItemDisplay -> values.addAll(buildItemMetadata(fake))
}
return ClientboundSetEntityDataPacket(fake.entityId, values)
}
fun createMetadataPacket(fake: FakeInteraction): ClientboundSetEntityDataPacket {
val values = buildInteractionMetadata(fake)
return ClientboundSetEntityDataPacket(fake.entityId, values)
}
// --- Bundle (spawn + metadata) ---
fun createSpawnBundle(fake: FakeDisplay): ClientboundBundlePacket {
return ClientboundBundlePacket(
listOf(createSpawnPacket(fake), createMetadataPacket(fake))
)
}
fun createSpawnBundle(fake: FakeInteraction): ClientboundBundlePacket {
return ClientboundBundlePacket(
listOf(createSpawnPacket(fake), createMetadataPacket(fake))
)
}
// --- Remove ---
fun createRemovePacket(vararg entityIds: Int): ClientboundRemoveEntitiesPacket {
return ClientboundRemoveEntitiesPacket(*entityIds)
}
// --- Teleport ---
fun createTeleportPacket(fake: FakeEntity): ClientboundTeleportEntityPacket {
val loc = fake.location
return ClientboundTeleportEntityPacket(
fake.entityId,
PositionMoveRotation(Vec3(loc.x, loc.y, loc.z), Vec3.ZERO, loc.yaw, loc.pitch),
java.util.Set.of(),
false
)
}
// --- Send ---
fun sendPacket(player: Player, packet: Packet<*>) {
(player as CraftPlayer).handle.connection.send(packet)
}
// --- Metadata builders ---
private fun buildDisplayMetadata(fake: FakeDisplay): MutableList<SynchedEntityData.DataValue<*>> {
val values = mutableListOf<SynchedEntityData.DataValue<*>>()
var sharedFlags: Byte = 0
if (fake.isGlowing) sharedFlags = (sharedFlags.toInt() or 0x40).toByte()
values.add(SynchedEntityData.DataValue.create(EntityDataFields.SHARED_FLAGS, sharedFlags))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.TRANSLATION, fake.translation))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.SCALE, fake.scale))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.LEFT_ROTATION, fake.leftRotation))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.RIGHT_ROTATION, fake.rightRotation))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.INTERPOLATION_DELAY, fake.interpolationDelay))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.INTERPOLATION_DURATION, fake.interpolationDuration))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.TELEPORT_INTERPOLATION_DURATION, fake.teleportInterpolationDuration))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.BILLBOARD, billboardToByte(fake.billboard)))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.BRIGHTNESS_OVERRIDE, packBrightness(fake.brightness)))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.VIEW_RANGE, fake.viewRange))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.SHADOW_RADIUS, fake.shadowRadius))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.SHADOW_STRENGTH, fake.shadowStrength))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.DISPLAY_WIDTH, fake.displayWidth))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.DISPLAY_HEIGHT, fake.displayHeight))
values.add(SynchedEntityData.DataValue.create(EntityDataFields.GLOW_COLOR_OVERRIDE, fake.glowColorOverride))
return values
}
private fun buildTextMetadata(fake: FakeTextDisplay): List<SynchedEntityData.DataValue<*>> {
return listOf(
SynchedEntityData.DataValue.create(EntityDataFields.TEXT, PaperAdventure.asVanilla(fake.text)),
SynchedEntityData.DataValue.create(EntityDataFields.LINE_WIDTH, fake.lineWidth),
SynchedEntityData.DataValue.create(EntityDataFields.BACKGROUND_COLOR, fake.backgroundColor),
SynchedEntityData.DataValue.create(EntityDataFields.TEXT_OPACITY, fake.textOpacity),
SynchedEntityData.DataValue.create(EntityDataFields.STYLE_FLAGS, fake.styleFlags)
)
}
private fun buildBlockMetadata(fake: FakeBlockDisplay): List<SynchedEntityData.DataValue<*>> {
val nmsBlockState = (fake.blockData as CraftBlockData).state
return listOf(
SynchedEntityData.DataValue.create(EntityDataFields.BLOCK_STATE, nmsBlockState)
)
}
private fun buildItemMetadata(fake: FakeItemDisplay): List<SynchedEntityData.DataValue<*>> {
val nmsItemStack = CraftItemStack.asNMSCopy(fake.itemStack)
return listOf(
SynchedEntityData.DataValue.create(EntityDataFields.ITEM_STACK, nmsItemStack),
SynchedEntityData.DataValue.create(EntityDataFields.ITEM_DISPLAY_TRANSFORM, fake.itemDisplayTransform.ordinal.toByte())
)
}
private fun buildInteractionMetadata(fake: FakeInteraction): MutableList<SynchedEntityData.DataValue<*>> {
return mutableListOf(
SynchedEntityData.DataValue.create(EntityDataFields.INTERACTION_WIDTH, fake.width),
SynchedEntityData.DataValue.create(EntityDataFields.INTERACTION_HEIGHT, fake.height),
SynchedEntityData.DataValue.create(EntityDataFields.INTERACTION_RESPONSE, fake.responsive)
)
}
private fun billboardToByte(billboard: Billboard): Byte = billboard.ordinal.toByte()
private fun packBrightness(brightness: org.bukkit.entity.Display.Brightness?): Int {
if (brightness == null) return -1
return brightness.blockLight or (brightness.skyLight shl 4)
}
}
@@ -0,0 +1,240 @@
package net.hareworks.ghostdisplays.internal.nms
import java.lang.invoke.MethodHandles
import net.minecraft.network.chat.Component as NmsComponent
import net.minecraft.network.syncher.EntityDataAccessor
import net.minecraft.world.entity.Display as NmsDisplay
import net.minecraft.world.entity.Interaction as NmsInteraction
import net.minecraft.world.item.ItemStack as NmsItemStack
import net.minecraft.world.level.block.state.BlockState
import org.joml.Quaternionf
import org.joml.Vector3f
@Suppress("UNCHECKED_CAST")
internal object EntityDataFields {
// --- Entity (common) ---
private val entityLookup = MethodHandles.privateLookupIn(
net.minecraft.world.entity.Entity::class.java, MethodHandles.lookup()
)
val SHARED_FLAGS: EntityDataAccessor<Byte> =
entityLookup.findStaticGetter(
net.minecraft.world.entity.Entity::class.java,
"DATA_SHARED_FLAGS_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Byte>
// --- Display (common) ---
private val displayLookup = MethodHandles.privateLookupIn(
NmsDisplay::class.java, MethodHandles.lookup()
)
val INTERPOLATION_DELAY: EntityDataAccessor<Int> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_TRANSFORMATION_INTERPOLATION_START_DELTA_TICKS_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Int>
val INTERPOLATION_DURATION: EntityDataAccessor<Int> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_TRANSFORMATION_INTERPOLATION_DURATION_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Int>
val TELEPORT_INTERPOLATION_DURATION: EntityDataAccessor<Int> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_POS_ROT_INTERPOLATION_DURATION_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Int>
val TRANSLATION: EntityDataAccessor<Vector3f> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_TRANSLATION_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Vector3f>
val SCALE: EntityDataAccessor<Vector3f> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_SCALE_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Vector3f>
val LEFT_ROTATION: EntityDataAccessor<Quaternionf> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_LEFT_ROTATION_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Quaternionf>
val RIGHT_ROTATION: EntityDataAccessor<Quaternionf> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_RIGHT_ROTATION_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Quaternionf>
val BILLBOARD: EntityDataAccessor<Byte> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_BILLBOARD_RENDER_CONSTRAINTS_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Byte>
val BRIGHTNESS_OVERRIDE: EntityDataAccessor<Int> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_BRIGHTNESS_OVERRIDE_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Int>
val VIEW_RANGE: EntityDataAccessor<Float> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_VIEW_RANGE_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Float>
val SHADOW_RADIUS: EntityDataAccessor<Float> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_SHADOW_RADIUS_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Float>
val SHADOW_STRENGTH: EntityDataAccessor<Float> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_SHADOW_STRENGTH_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Float>
val DISPLAY_WIDTH: EntityDataAccessor<Float> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_WIDTH_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Float>
val DISPLAY_HEIGHT: EntityDataAccessor<Float> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_HEIGHT_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Float>
val GLOW_COLOR_OVERRIDE: EntityDataAccessor<Int> =
displayLookup.findStaticGetter(
NmsDisplay::class.java,
"DATA_GLOW_COLOR_OVERRIDE_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Int>
// --- TextDisplay ---
private val textDisplayLookup = MethodHandles.privateLookupIn(
NmsDisplay.TextDisplay::class.java, MethodHandles.lookup()
)
val TEXT: EntityDataAccessor<NmsComponent> =
textDisplayLookup.findStaticGetter(
NmsDisplay.TextDisplay::class.java,
"DATA_TEXT_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<NmsComponent>
val LINE_WIDTH: EntityDataAccessor<Int> =
textDisplayLookup.findStaticGetter(
NmsDisplay.TextDisplay::class.java,
"DATA_LINE_WIDTH_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Int>
val BACKGROUND_COLOR: EntityDataAccessor<Int> =
textDisplayLookup.findStaticGetter(
NmsDisplay.TextDisplay::class.java,
"DATA_BACKGROUND_COLOR_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Int>
val TEXT_OPACITY: EntityDataAccessor<Byte> =
textDisplayLookup.findStaticGetter(
NmsDisplay.TextDisplay::class.java,
"DATA_TEXT_OPACITY_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Byte>
val STYLE_FLAGS: EntityDataAccessor<Byte> =
textDisplayLookup.findStaticGetter(
NmsDisplay.TextDisplay::class.java,
"DATA_STYLE_FLAGS_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Byte>
// --- BlockDisplay ---
private val blockDisplayLookup = MethodHandles.privateLookupIn(
NmsDisplay.BlockDisplay::class.java, MethodHandles.lookup()
)
val BLOCK_STATE: EntityDataAccessor<BlockState> =
blockDisplayLookup.findStaticGetter(
NmsDisplay.BlockDisplay::class.java,
"DATA_BLOCK_STATE_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<BlockState>
// --- ItemDisplay ---
private val itemDisplayLookup = MethodHandles.privateLookupIn(
NmsDisplay.ItemDisplay::class.java, MethodHandles.lookup()
)
val ITEM_STACK: EntityDataAccessor<NmsItemStack> =
itemDisplayLookup.findStaticGetter(
NmsDisplay.ItemDisplay::class.java,
"DATA_ITEM_STACK_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<NmsItemStack>
val ITEM_DISPLAY_TRANSFORM: EntityDataAccessor<Byte> =
itemDisplayLookup.findStaticGetter(
NmsDisplay.ItemDisplay::class.java,
"DATA_ITEM_DISPLAY_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Byte>
// --- Interaction ---
private val interactionLookup = MethodHandles.privateLookupIn(
NmsInteraction::class.java, MethodHandles.lookup()
)
val INTERACTION_WIDTH: EntityDataAccessor<Float> =
interactionLookup.findStaticGetter(
NmsInteraction::class.java,
"DATA_WIDTH_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Float>
val INTERACTION_HEIGHT: EntityDataAccessor<Float> =
interactionLookup.findStaticGetter(
NmsInteraction::class.java,
"DATA_HEIGHT_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Float>
val INTERACTION_RESPONSE: EntityDataAccessor<Boolean> =
interactionLookup.findStaticGetter(
NmsInteraction::class.java,
"DATA_RESPONSE_ID",
EntityDataAccessor::class.java
).invoke() as EntityDataAccessor<Boolean>
}
@@ -0,0 +1,112 @@
package net.hareworks.ghostdisplays.internal.nms
import io.netty.channel.ChannelDuplexHandler
import io.netty.channel.ChannelHandlerContext
import java.util.logging.Level
import net.minecraft.network.protocol.game.ServerboundAttackPacket
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, data: InteractData) -> Unit
) : Listener {
companion object {
private const val HANDLER_NAME = "ghostdisplays_interact"
}
fun injectAll() {
plugin.server.onlinePlayers.forEach { inject(it) }
}
fun inject(player: Player) {
val channel = (player as CraftPlayer).handle.connection.connection.channel
channel.eventLoop().execute {
if (channel.pipeline().get(HANDLER_NAME) != null) return@execute
channel.pipeline().addBefore("packet_handler", HANDLER_NAME, createHandler(player))
}
}
fun uninject(player: Player) {
val channel = (player as CraftPlayer).handle.connection.connection.channel
channel.eventLoop().execute {
if (channel.pipeline().get(HANDLER_NAME) != null) {
channel.pipeline().remove(HANDLER_NAME)
}
}
}
@EventHandler
fun onPlayerJoin(event: PlayerJoinEvent) {
inject(event.player)
}
@EventHandler
fun onPlayerQuit(event: PlayerQuitEvent) {
uninject(event.player)
}
private fun createHandler(player: Player): ChannelDuplexHandler {
return object : ChannelDuplexHandler() {
override fun channelRead(ctx: ChannelHandlerContext, msg: Any) {
try {
when (msg) {
is ServerboundInteractPacket -> {
val pos = msg.location()
onInteract(
player,
InteractData(
entityId = msg.entityId(),
action = InteractAction.INTERACT_AT,
hand = toBukkit(msg.hand()),
clickPosition = org.bukkit.util.Vector(pos.x, pos.y, pos.z),
),
)
}
is ServerboundAttackPacket -> {
onInteract(
player,
InteractData(
entityId = msg.entityId(),
action = InteractAction.ATTACK,
hand = null,
clickPosition = null,
),
)
}
}
} catch (ex: Throwable) {
plugin.logger.log(Level.WARNING, "Failed to handle interact packet", ex)
}
super.channelRead(ctx, msg)
}
}
}
private fun toBukkit(hand: InteractionHand): EquipmentSlot = when (hand) {
InteractionHand.MAIN_HAND -> EquipmentSlot.HAND
InteractionHand.OFF_HAND -> EquipmentSlot.OFF_HAND
}
}