This commit is contained in:
Keisuke Hirata 2025-12-06 04:40:18 +09:00
commit 4a302f5f6f
27 changed files with 1894 additions and 0 deletions

12
.gitattributes vendored Normal file
View File

@ -0,0 +1,12 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# Linux start script should use lf
/gradlew text eol=lf
# These are Windows script files and should use crlf
*.bat text eol=crlf
# Binary files should be left untouched
*.jar binary

5
.gitignore vendored Normal file
View File

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

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "kommand-lib"]
path = kommand-lib
url = git@gitea.hareworks.net:Hare/kommand-lib.git

40
README.md Normal file
View File

@ -0,0 +1,40 @@
# GhostDisplays
Paper 1.21.10 向けの不可視ディスプレイ制御ライブラリです。TextDisplay / BlockDisplay / ItemDisplay をプレイヤー単位で表示・クリック検知できるようにし、ゴースト化した UI やガイドラインをサーバー側で柔軟に提供できます。
## 提供機能
- `DisplayService` による Text/Block/Item Display の生成 API
- `DisplayController` を介した `Player#showEntity` / `hideEntity` の参照カウント管理
- `AudiencePredicate`/`AudiencePredicates` による可視対象の自動同期(ログイン・ワールド移動・リスポーンで再評価)
- Interaction エンティティを用いたクリック判定サポートと、優先度付きクリックハンドラー
- まとめて `destroyAll()` できるリソース管理
## スタンドアロンでの使い方
プラグインを `plugins/` に配置して起動すると `/ghostdisplay` コマンドが利用できます。デフォルトで OP のみ実行可能ですが、`ghostdisplays.command.*` を付与すると通常権限でも操作できます。
| コマンド | 説明 |
| --- | --- |
| `/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 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` で全自動表示を解除)。 |
| `/ghostdisplay list` / `/ghostdisplay info <id>` | 登録済み Display の一覧、情報(座標 / Viewers / Audiences / 内容)を表示。 |
| `/ghostdisplay delete <id>` | Display を完全に削除します。 |
## 技術的ポイント
- Paper 標準 API の `setVisibleByDefault(false)` + `showEntity`/`hideEntity` を採用し、ProtocolLib に依存しない設計
- Display / Interaction は `setPersistent(false)` でスポーンし、サーバーリロードやチャンクアンロードに強い
- Predicate ごとにアクティブなプレイヤー集合を持つため、複数条件での重複表示にも対応
- クリック検知は `PlayerInteractEntityEvent` を介して Display/Interaction 双方から観測し、ハンドラーは Kotlin DSL で登録可能
- Kotlin 2.2 + ShadowJar + plugin-yml 生成を使い、`./gradlew build`でそのまま Paper に投入可能なJarを生成
## 同梱ライブラリ
- コマンド定義は `kommand-lib`Kotlin DSLを使用し、`permits-lib` と連携して `ghostdisplays.command.*` のパーミッションツリーを自動生成しています。
- どちらも本リポジトリの `kommand-lib/` 以下にサブモジュールとして含まれており、`./gradlew build` 時に一緒にコンパイルされます。

46
build.gradle.kts Normal file
View File

@ -0,0 +1,46 @@
group = "net.hareworks"
version = "1.0"
plugins {
kotlin("jvm") version "2.2.21"
id("de.eldoria.plugin-yml.paper") version "0.8.0"
id("com.gradleup.shadow") version "9.2.2"
}
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
implementation("org.jetbrains.kotlin:kotlin-stdlib")
implementation("net.hareworks:kommand-lib:1.1")
implementation("net.hareworks:permits-lib:1.1")
implementation("net.kyori:adventure-text-minimessage:4.17.0")
implementation("net.kyori:adventure-text-serializer-plain:4.17.0")
}
kotlin {
jvmToolchain(21)
}
paper {
main = "net.hareworks.ghostdisplays.GhostDisplaysPlugin"
name = "GhostDisplays"
version = project.version as String
apiVersion = "1.21"
description = "Invisible display entity controller library."
authors = listOf("Hareworks")
}
tasks {
withType<Jar> {
archiveBaseName.set("GhostDisplays")
}
shadowJar {
archiveClassifier.set("min")
minimize {
exclude("net.hareworks:kommand-lib")
exclude("net.hareworks:permits-lib")
}
}
}

5
gradle.properties Normal file
View File

@ -0,0 +1,5 @@
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.caching=true
kotlin.stdlib.default.dependency=false

View File

@ -0,0 +1,2 @@
# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
kommand-lib Submodule

@ -0,0 +1 @@
Subproject commit 559341c0415699a2b39a1a523f4ef4ed93ce3fdc

View File

@ -0,0 +1,66 @@
package net.hareworks.ghostdisplays
import net.hareworks.ghostdisplays.api.DisplayService
import net.hareworks.ghostdisplays.command.CommandRegistrar
import net.hareworks.ghostdisplays.display.DisplayManager
import net.hareworks.ghostdisplays.display.EditSessionManager
import net.hareworks.ghostdisplays.internal.DefaultDisplayService
import net.hareworks.ghostdisplays.internal.controller.DisplayRegistry
import net.hareworks.kommand_lib.KommandLib
import net.hareworks.permits_lib.PermitsLib
import net.hareworks.permits_lib.bukkit.MutationSession
import net.kyori.adventure.text.minimessage.MiniMessage
import org.bukkit.plugin.ServicePriority
import org.bukkit.plugin.java.JavaPlugin
class GhostDisplaysPlugin : JavaPlugin() {
private lateinit var displayRegistry: DisplayRegistry
private lateinit var serviceImpl: DisplayService
private lateinit var displayManager: DisplayManager
private lateinit var editSessions: EditSessionManager
private lateinit var miniMessage: MiniMessage
private var permissionSession: MutationSession? = null
private var kommand: KommandLib? = null
override fun onEnable() {
miniMessage = MiniMessage.miniMessage()
displayRegistry = DisplayRegistry().also {
server.pluginManager.registerEvents(it, this)
}
serviceImpl = DefaultDisplayService(this, displayRegistry)
displayManager = DisplayManager(this, serviceImpl, miniMessage)
editSessions = EditSessionManager(this, displayManager).also {
server.pluginManager.registerEvents(it, this)
}
server.servicesManager.register(DisplayService::class.java, serviceImpl, this, ServicePriority.Normal)
permissionSession = PermitsLib.session(this)
kommand = CommandRegistrar.register(this, displayManager, editSessions, permissionSession!!)
instance = this
logger.info("GhostDisplays ready: ${displayRegistry.controllerCount()} controllers active.")
}
override fun onDisable() {
kommand?.unregister()
kommand = null
runCatching { editSessions.shutdown() }
runCatching { displayManager.destroyAll() }
runCatching { serviceImpl.destroyAll() }
permissionSession = null
server.servicesManager.unregister(DisplayService::class.java, serviceImpl)
displayRegistry.shutdown()
instance = null
}
companion object {
@Volatile
private var instance: GhostDisplaysPlugin? = null
fun service(): DisplayService =
instance?.serviceImpl ?: throw IllegalStateException("GhostDisplays plugin not enabled")
fun manager(): DisplayManager =
instance?.displayManager ?: throw IllegalStateException("GhostDisplays plugin not enabled")
}
}

View File

@ -0,0 +1,33 @@
package net.hareworks.ghostdisplays.api
import net.hareworks.ghostdisplays.api.audience.AudiencePredicate
import net.hareworks.ghostdisplays.api.click.ClickPriority
import net.hareworks.ghostdisplays.api.click.DisplayClickHandler
import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import org.bukkit.entity.Display
import org.bukkit.entity.Interaction
import org.bukkit.entity.Player
import java.util.UUID
interface DisplayController<T : Display> {
val display: T
val interaction: Interaction?
fun show(player: Player)
fun hide(player: Player)
fun isViewing(playerId: UUID): Boolean
fun viewerIds(): Set<UUID>
fun applyEntityUpdate(mutator: (T) -> Unit)
fun addAudience(predicate: AudiencePredicate): HandlerRegistration
fun refreshAudience(target: Player? = null)
fun destroy()
fun onClick(priority: ClickPriority = ClickPriority.NORMAL, handler: DisplayClickHandler): HandlerRegistration
}

View File

@ -0,0 +1,30 @@
package net.hareworks.ghostdisplays.api
import org.bukkit.Location
import org.bukkit.entity.BlockDisplay
import org.bukkit.entity.ItemDisplay
import org.bukkit.entity.TextDisplay
import org.bukkit.inventory.ItemStack
interface DisplayService {
fun createTextDisplay(
location: Location,
interaction: InteractionOptions = InteractionOptions.Disabled,
builder: TextDisplay.() -> Unit = {}
): DisplayController<TextDisplay>
fun createBlockDisplay(
location: Location,
interaction: InteractionOptions = InteractionOptions.Disabled,
builder: BlockDisplay.() -> Unit = {}
): DisplayController<BlockDisplay>
fun createItemDisplay(
location: Location,
itemStack: ItemStack,
interaction: InteractionOptions = InteractionOptions.Disabled,
builder: ItemDisplay.() -> Unit = {}
): DisplayController<ItemDisplay>
fun destroyAll()
}

View File

@ -0,0 +1,28 @@
package net.hareworks.ghostdisplays.api
import kotlin.math.max
/**
* 設置されたDisplay上にクリック判定用のInteractionエンティティを重ねる際のオプション
*/
data class InteractionOptions(
val enabled: Boolean = false,
val width: Double = 0.8,
val height: Double = 0.8,
val responsive: Boolean = true
) {
init {
require(width > 0.0) { "width must be positive" }
require(height > 0.0) { "height must be positive" }
}
fun effectiveWidth(): Double = max(width, 0.1)
fun effectiveHeight(): Double = max(height, 0.1)
companion object {
val Disabled = InteractionOptions()
fun enabled(width: Double = 0.8, height: Double = 0.8, responsive: Boolean = true): InteractionOptions =
InteractionOptions(true, width, height, responsive)
}
}

View File

@ -0,0 +1,10 @@
package net.hareworks.ghostdisplays.api.audience
import org.bukkit.entity.Player
/**
* Displayを視認させる対象かどうかを判定する述語
*/
fun interface AudiencePredicate {
fun test(player: Player): Boolean
}

View File

@ -0,0 +1,30 @@
package net.hareworks.ghostdisplays.api.audience
import org.bukkit.Location
import org.bukkit.entity.Player
import java.util.UUID
object AudiencePredicates {
fun anyone(): AudiencePredicate = AudiencePredicate { true }
fun permission(node: String): AudiencePredicate = AudiencePredicate { player ->
player.hasPermission(node)
}
fun world(worldName: String): AudiencePredicate = AudiencePredicate { player ->
player.world.name.equals(worldName, ignoreCase = true)
}
fun uuid(target: UUID): AudiencePredicate = AudiencePredicate { player ->
player.uniqueId == target
}
fun near(location: Location, radius: Double): AudiencePredicate {
val radiusSq = radius * radius
val worldName = location.world?.name
require(worldName != null) { "Location must have a world" }
return AudiencePredicate { player ->
player.world.name == worldName && player.location.distanceSquared(location) <= radiusSq
}
}
}

View File

@ -0,0 +1,35 @@
package net.hareworks.ghostdisplays.api.click
import net.hareworks.ghostdisplays.api.DisplayController
import org.bukkit.entity.Entity
import org.bukkit.entity.Player
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.inventory.EquipmentSlot
enum class ClickPriority {
HIGH,
NORMAL,
LOW
}
enum class ClickSurface {
DISPLAY,
INTERACTION
}
data class DisplayClickContext(
val player: Player,
val hand: EquipmentSlot?,
val clickedEntity: Entity,
val surface: ClickSurface,
val controller: DisplayController<*>,
val event: PlayerInteractEntityEvent
)
fun interface DisplayClickHandler {
fun handle(context: DisplayClickContext)
}
fun interface HandlerRegistration {
fun unregister()
}

View File

@ -0,0 +1,417 @@
package net.hareworks.ghostdisplays.command
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import net.hareworks.ghostdisplays.display.DisplayManager
import net.hareworks.ghostdisplays.display.DisplayManager.DisplayOperationException
import net.hareworks.ghostdisplays.display.DisplayKind
import net.hareworks.ghostdisplays.display.EditSessionManager
import net.hareworks.kommand_lib.KommandLib
import net.hareworks.kommand_lib.kommand
import net.hareworks.permits_lib.bukkit.MutationSession
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.block.data.BlockData
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.bukkit.permissions.PermissionDefault
import org.bukkit.plugin.java.JavaPlugin
object CommandRegistrar {
fun register(
plugin: JavaPlugin,
manager: DisplayManager,
editSessions: EditSessionManager,
permissionSession: MutationSession
): KommandLib =
kommand(plugin) {
permissions {
namespace = "ghostdisplays"
defaultValue = PermissionDefault.OP
wildcard = true
session(permissionSession)
}
command("ghostdisplay", listOf("gdisplay", "gdisp")) {
description = "Manage GhostDisplays entities"
permission = "ghostdisplays.command"
executes { sender.showUsage() }
literal("help") {
executes { sender.showUsage() }
}
literal("create") {
literal("text") {
string("id") {
executes {
val player = sender.requirePlayer() ?: return@executes
val id = argument<String>("id")
val location = player.anchorLocation()
try {
val display = manager.createTextDisplay(id, location, "", player)
editSessions.begin(player, display.id)
sender.success("Text display '${display.id}' created at ${describe(location)}. Type text in chat (or 'cancel') to set content.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to create text display.")
}
}
}
}
literal("block") {
string("id") {
string("state") {
executes {
val player = sender.requirePlayer() ?: return@executes
val id = argument<String>("id")
val state = argument<String>("state")
try {
val blockData = parseBlockData(state)
val display = manager.createBlockDisplay(id, player.anchorLocation(), blockData, player)
sender.success("Block display '${display.id}' created with $state at ${describe(display.location)}.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to create block display.")
} catch (ex: IllegalArgumentException) {
sender.failure("Invalid block data '$state': ${ex.message}")
}
}
}
}
}
literal("item") {
string("id") {
string("material") {
executes {
val player = sender.requirePlayer() ?: return@executes
val id = argument<String>("id")
val materialToken = argument<String>("material")
val material = Material.matchMaterial(materialToken.uppercase(Locale.ROOT))
?: return@executes sender.failure("Unknown material '$materialToken'.")
if (!material.isItem) {
return@executes sender.failure("$materialToken cannot be used as an item display.")
}
try {
val item = ItemStack(material)
val display = manager.createItemDisplay(id, player.anchorLocation(), item, player)
sender.success("Item display '${display.id}' created with ${material.name.lowercase()} at ${describe(display.location)}.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to create item display.")
}
}
}
}
}
}
literal("delete") {
string("id") {
executes {
val id = argument<String>("id")
try {
manager.delete(id)
sender.success("Display '$id' removed.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to delete '$id'.")
}
}
}
}
literal("list") {
executes {
val entries = manager.listDisplays()
if (entries.isEmpty()) {
sender.info("No displays registered.")
return@executes
}
sender.info("Displays (${entries.size}):")
entries.forEach {
sender.info(" - ${it.id} [${it.kind}] @ ${describe(it.location)} viewers=${it.controller.viewerIds().size}")
}
}
}
literal("info") {
string("id") {
executes {
val id = argument<String>("id")
val display = manager.findDisplay(id)
if (display == null) {
sender.failure("Display '$id' not found.")
return@executes
}
sender.info("Display '${display.id}' (${display.kind})")
sender.info(" Location: ${describe(display.location)}")
sender.info(" Created: ${DATE_FORMAT.format(display.createdAt)} by ${display.createdBy?.let { Bukkit.getOfflinePlayer(it).name ?: it } ?: "unknown"}")
val viewers = display.controller.viewerIds()
sender.info(" Viewers (${viewers.size}): ${resolveNames(viewers).ifEmpty { "none" }}")
val audiences = display.audienceBindings()
sender.info(" Audiences (${audiences.size}): ${if (audiences.isEmpty()) "none" else audiences.joinToString { it.description }}")
sender.info(" Content: ${manager.describeContent(display)}")
}
}
}
literal("viewer") {
literal("add") {
string("id") {
players("targets") {
executes {
val id = argument<String>("id")
val targets = argument<List<Player>>("targets")
try {
manager.showToPlayers(id, targets)
sender.success("Showing '$id' to ${targets.size} player(s).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update viewers.")
}
}
}
}
}
literal("remove") {
string("id") {
players("targets") {
executes {
val id = argument<String>("id")
val targets = argument<List<Player>>("targets")
try {
manager.hideFromPlayers(id, targets)
sender.success("Hid '$id' from ${targets.size} player(s).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update viewers.")
}
}
}
}
}
literal("clear") {
string("id") {
executes {
val id = argument<String>("id")
try {
manager.clearViewers(id)
sender.success("Cleared active viewers for '$id'.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to clear viewers.")
}
}
}
}
}
literal("text") {
literal("edit") {
string("id") {
executes {
val player = sender.requirePlayer() ?: return@executes
val id = argument<String>("id")
val display = manager.findDisplay(id)
if (display == null || display.kind != DisplayKind.TEXT) {
sender.failure("Text display '$id' not found.")
return@executes
}
editSessions.begin(player, display.id)
sender.info("Enter new MiniMessage text in chat for '${display.id}'. Type 'cancel' to abort.")
}
}
}
literal("set") {
string("id") {
string("content") {
executes {
val id = argument<String>("id")
val token = argument<String>("content")
val raw = token.replace('_', ' ').replace("\\n", "\n")
try {
manager.updateText(id, raw)
sender.success("Updated text for '$id'. Use /ghostdisplay text edit $id for multi-line content.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update text.")
}
}
}
}
}
literal("cancel") {
executes {
val player = sender.requirePlayer() ?: return@executes
if (editSessions.cancel(player)) {
sender.success("Editing session cancelled.")
} else {
sender.failure("You are not editing any display.")
}
}
}
}
literal("block") {
literal("set") {
string("id") {
string("state") {
executes {
val id = argument<String>("id")
val state = argument<String>("state")
try {
val blockData = parseBlockData(state)
manager.updateBlock(id, blockData)
sender.success("Block data for '$id' updated to $state.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update block display.")
} catch (ex: IllegalArgumentException) {
sender.failure("Invalid block data '$state': ${ex.message}")
}
}
}
}
}
}
literal("item") {
literal("set") {
string("id") {
string("material") {
executes {
val id = argument<String>("id")
val materialToken = argument<String>("material")
val material = Material.matchMaterial(materialToken.uppercase(Locale.ROOT))
?: return@executes sender.failure("Unknown material '$materialToken'.")
if (!material.isItem) {
return@executes sender.failure("$materialToken cannot be used as an item display.")
}
try {
manager.updateItem(id, ItemStack(material))
sender.success("Item for '$id' set to ${material.name.lowercase()}.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update item display.")
}
}
}
}
}
}
literal("audience") {
literal("permission") {
literal("add") {
string("id") {
string("permission") {
executes {
val id = argument<String>("id")
val perm = argument<String>("permission")
try {
manager.addPermissionAudience(id, perm)
sender.success("Permission audience '$perm' added to '$id'.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to add permission audience.")
}
}
}
}
}
literal("remove") {
string("id") {
string("permission") {
executes {
val id = argument<String>("id")
val perm = argument<String>("permission")
val removed = runCatching { manager.removePermissionAudience(id, perm) }
.onFailure { sender.failure(it.message ?: "Failed to remove permission audience.") }
.getOrNull()
if (removed == true) {
sender.success("Permission audience '$perm' removed from '$id'.")
} else if (removed == false) {
sender.failure("Permission audience '$perm' not found for '$id'.")
}
}
}
}
}
}
literal("near") {
literal("set") {
string("id") {
float("radius", min = 1.0) {
executes {
val id = argument<String>("id")
val radius = argument<Double>("radius")
try {
manager.setNearAudience(id, radius)
sender.success("Radius audience for '$id' set to ${String.format("%.1f", radius)} block(s).")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to update radius audience.")
}
}
}
}
}
}
literal("clear") {
string("id") {
executes {
val id = argument<String>("id")
try {
manager.clearAudiences(id)
sender.success("Audiences cleared for '$id'.")
} catch (ex: DisplayOperationException) {
sender.failure(ex.message ?: "Failed to clear audiences.")
}
}
}
}
}
}
}
}
private fun CommandSender.showUsage() {
info("GhostDisplays commands:")
info(" /ghostdisplay create <text|block|item> ...")
info(" /ghostdisplay text edit <id> - edit text via chat")
info(" /ghostdisplay viewer <add|remove|clear> ...")
info(" /ghostdisplay audience <permission|near|clear> ...")
info(" /ghostdisplay list | info <id> | delete <id>")
}
private fun Player.anchorLocation(): Location =
eyeLocation.clone().add(direction.normalize().multiply(1.5))
private fun parseBlockData(state: String): BlockData =
Bukkit.createBlockData(state)
private fun describe(location: Location): String =
"${location.world?.name ?: "unknown"} @ ${location.blockX},${location.blockY},${location.blockZ}"
private fun CommandSender.requirePlayer(): Player? {
val player = this as? Player
if (player == null) {
failure("This command can only be used by players.")
}
return player
}
private fun CommandSender.info(message: String) {
sendMessage("§7$message")
}
private fun CommandSender.success(message: String) {
sendMessage("§a$message")
}
private fun CommandSender.failure(message: String) {
sendMessage("§c$message")
}
private fun resolveNames(ids: Collection<UUID>): String {
if (ids.isEmpty()) return ""
return ids.joinToString { Bukkit.getOfflinePlayer(it).name ?: it.toString() }
}
private val DATE_FORMAT: DateTimeFormatter =
DateTimeFormatter.ISO_INSTANT

View File

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

View File

@ -0,0 +1,56 @@
package net.hareworks.ghostdisplays.display
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import net.hareworks.ghostdisplays.display.DisplayManager.DisplayOperationException
import org.bukkit.Bukkit
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.player.AsyncPlayerChatEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.plugin.java.JavaPlugin
class EditSessionManager(
private val plugin: JavaPlugin,
private val manager: DisplayManager
) : Listener {
private val sessions = ConcurrentHashMap<UUID, String>()
fun begin(player: Player, displayId: String) {
sessions[player.uniqueId] = displayId
}
fun cancel(player: Player): Boolean = sessions.remove(player.uniqueId) != null
fun shutdown() {
sessions.clear()
}
@EventHandler(ignoreCancelled = true)
fun onPlayerChat(event: AsyncPlayerChatEvent) {
val displayId = sessions[event.player.uniqueId] ?: return
event.isCancelled = true
val message = event.message
if (message.equals("cancel", ignoreCase = true)) {
sessions.remove(event.player.uniqueId)
event.player.sendMessage("GhostDisplays: editing for '$displayId' cancelled.")
return
}
Bukkit.getScheduler().runTask(plugin) {
try {
manager.updateText(displayId, message)
event.player.sendMessage("GhostDisplays: updated text for '$displayId'.")
} catch (ex: DisplayOperationException) {
event.player.sendMessage("GhostDisplays: ${ex.message}")
} finally {
sessions.remove(event.player.uniqueId)
}
}
}
@EventHandler
fun onPlayerQuit(event: PlayerQuitEvent) {
sessions.remove(event.player.uniqueId)
}
}

View File

@ -0,0 +1,89 @@
package net.hareworks.ghostdisplays.display
import java.time.Instant
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import net.hareworks.ghostdisplays.api.DisplayController
import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import net.kyori.adventure.text.Component
import org.bukkit.Location
import org.bukkit.block.data.BlockData
import org.bukkit.entity.BlockDisplay
import org.bukkit.entity.Display
import org.bukkit.entity.ItemDisplay
import org.bukkit.entity.TextDisplay
import org.bukkit.inventory.ItemStack
enum class DisplayKind {
TEXT,
BLOCK,
ITEM
}
data class AudienceBinding(
val key: String,
val description: String,
private val registration: HandlerRegistration
) {
fun unregister() {
registration.unregister()
}
}
sealed class ManagedDisplay<T : Display>(
val id: String,
val kind: DisplayKind,
val controller: DisplayController<T>,
location: Location,
val createdAt: Instant,
val createdBy: UUID?
) {
val location: Location = location.clone()
private val audienceBindings = ConcurrentHashMap<String, AudienceBinding>()
fun audienceBindings(): Collection<AudienceBinding> = audienceBindings.values
fun registerAudienceBinding(binding: AudienceBinding) {
audienceBindings.put(binding.key, binding)?.unregister()
}
fun removeAudienceBinding(key: String): Boolean {
return audienceBindings.remove(key)?.let {
it.unregister()
true
} ?: false
}
fun clearAudiences() {
audienceBindings.values.forEach { it.unregister() }
audienceBindings.clear()
}
class Text(
id: String,
controller: DisplayController<TextDisplay>,
location: Location,
createdAt: Instant,
createdBy: UUID?,
var rawContent: String,
var component: Component
) : ManagedDisplay<TextDisplay>(id, DisplayKind.TEXT, controller, location, createdAt, createdBy)
class Block(
id: String,
controller: DisplayController<BlockDisplay>,
location: Location,
createdAt: Instant,
createdBy: UUID?,
var blockData: BlockData
) : ManagedDisplay<BlockDisplay>(id, DisplayKind.BLOCK, controller, location, createdAt, createdBy)
class Item(
id: String,
controller: DisplayController<ItemDisplay>,
location: Location,
createdAt: Instant,
createdBy: UUID?,
var itemStack: ItemStack
) : ManagedDisplay<ItemDisplay>(id, DisplayKind.ITEM, controller, location, createdAt, createdBy)
}

View File

@ -0,0 +1,101 @@
package net.hareworks.ghostdisplays.internal
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CopyOnWriteArraySet
import net.hareworks.ghostdisplays.api.DisplayController
import net.hareworks.ghostdisplays.api.DisplayService
import net.hareworks.ghostdisplays.api.InteractionOptions
import net.hareworks.ghostdisplays.internal.controller.BaseDisplayController
import net.hareworks.ghostdisplays.internal.controller.DisplayRegistry
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.entity.BlockDisplay
import org.bukkit.entity.Display
import org.bukkit.entity.Entity
import org.bukkit.entity.Interaction
import org.bukkit.entity.ItemDisplay
import org.bukkit.entity.TextDisplay
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
internal class DefaultDisplayService(
private val plugin: JavaPlugin,
private val registry: DisplayRegistry
) : DisplayService {
private val controllers = CopyOnWriteArraySet<BaseDisplayController<out Display>>()
override fun createTextDisplay(
location: Location,
interaction: InteractionOptions,
builder: TextDisplay.() -> Unit
): DisplayController<TextDisplay> = spawnDisplay(location, TextDisplay::class.java, interaction) {
builder(it)
}
override fun createBlockDisplay(
location: Location,
interaction: InteractionOptions,
builder: BlockDisplay.() -> Unit
): DisplayController<BlockDisplay> = spawnDisplay(location, BlockDisplay::class.java, interaction) {
builder(it)
}
override fun createItemDisplay(
location: Location,
itemStack: ItemStack,
interaction: InteractionOptions,
builder: ItemDisplay.() -> Unit
): DisplayController<ItemDisplay> = spawnDisplay(location, ItemDisplay::class.java, interaction) {
it.itemStack = itemStack.clone()
builder(it)
}
override fun destroyAll() {
controllers.forEach { it.destroy() }
controllers.clear()
}
private fun <T : Display> spawnDisplay(
location: Location,
type: Class<T>,
interactionOptions: InteractionOptions,
afterSpawn: (T) -> Unit
): DisplayController<T> {
val entity = callSync {
val world = location.world ?: throw IllegalArgumentException("Location must have a world")
world.spawn(location, type) { spawned ->
spawned.setPersistent(false)
spawned.setVisibleByDefault(false)
}
}
callSync { afterSpawn(entity) }
val interaction = if (interactionOptions.enabled) spawnInteraction(location, interactionOptions) else null
val controller = BaseDisplayController(plugin, entity, interaction, registry)
controllers += controller
registry.register(controller)
return controller
}
private fun spawnInteraction(location: Location, options: InteractionOptions): Interaction =
callSync {
val world = location.world ?: throw IllegalArgumentException("Location must have a world")
world.spawn(location, Interaction::class.java) { interaction ->
interaction.setPersistent(false)
interaction.setInvisible(true)
interaction.setVisibleByDefault(false)
interaction.setResponsive(options.responsive)
interaction.setInteractionWidth(options.effectiveWidth().toFloat())
interaction.setInteractionHeight(options.effectiveHeight().toFloat())
}
}
private fun <T> callSync(action: () -> T): T {
return if (Bukkit.isPrimaryThread()) {
action()
} else {
val future = CompletableFuture<T>()
Bukkit.getScheduler().runTask(plugin) { future.complete(action()) }
future.join()
}
}
}

View File

@ -0,0 +1,199 @@
package net.hareworks.ghostdisplays.internal.controller
import java.util.HashSet
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import net.hareworks.ghostdisplays.api.DisplayController
import net.hareworks.ghostdisplays.api.audience.AudiencePredicate
import net.hareworks.ghostdisplays.api.click.ClickPriority
import net.hareworks.ghostdisplays.api.click.ClickSurface
import net.hareworks.ghostdisplays.api.click.DisplayClickContext
import net.hareworks.ghostdisplays.api.click.DisplayClickHandler
import net.hareworks.ghostdisplays.api.click.HandlerRegistration
import org.bukkit.Bukkit
import org.bukkit.entity.Display
import org.bukkit.entity.Entity
import org.bukkit.entity.Interaction
import org.bukkit.entity.Player
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.plugin.java.JavaPlugin
internal class BaseDisplayController<T : Display>(
private val plugin: JavaPlugin,
override val display: T,
override val interaction: Interaction?,
private val registry: DisplayRegistry
) : DisplayController<T> {
private val destroyed = AtomicBoolean(false)
private val viewerCounts = ConcurrentHashMap<UUID, Int>()
private val handlers = CopyOnWriteArrayList<HandlerEntry>()
private val audiences = CopyOnWriteArrayList<AudienceBindingImpl>()
override fun show(player: Player) {
runSync {
val uuid = player.uniqueId
val newCount = viewerCounts.compute(uuid) { _, count -> (count ?: 0) + 1 } ?: 1
if (newCount == 1) {
player.showEntity(plugin, display)
interaction?.let { player.showEntity(plugin, it) }
}
}
}
override fun hide(player: Player) {
runSync {
val uuid = player.uniqueId
val current = viewerCounts[uuid] ?: return@runSync
if (current <= 1) {
viewerCounts.remove(uuid)
player.hideEntity(plugin, display)
interaction?.let { player.hideEntity(plugin, it) }
} else {
viewerCounts[uuid] = current - 1
}
}
}
override fun isViewing(playerId: UUID): Boolean = viewerCounts.containsKey(playerId)
override fun viewerIds(): Set<UUID> = HashSet(viewerCounts.keys)
override fun applyEntityUpdate(mutator: (T) -> Unit) {
callSync {
mutator(display)
display.updateDisplay(false)
}
}
override fun destroy() {
if (!destroyed.compareAndSet(false, true)) return
registry.unregister(this)
val viewers = viewerIds().mapNotNull { Bukkit.getPlayer(it) }
runSync {
viewers.forEach {
it.hideEntity(plugin, display)
interaction?.let { interactionEntity -> it.hideEntity(plugin, interactionEntity) }
}
display.remove()
interaction?.remove()
viewerCounts.clear()
}
handlers.clear()
audiences.forEach { it.clear() }
audiences.clear()
}
override fun onClick(priority: ClickPriority, handler: DisplayClickHandler): HandlerRegistration {
val entry = HandlerEntry(priority, handler)
handlers += entry
return HandlerRegistration {
handlers.remove(entry)
}
}
override fun addAudience(predicate: AudiencePredicate): HandlerRegistration {
val binding = AudienceBindingImpl(predicate)
audiences += binding
refreshAudienceInternal(binding = binding)
return HandlerRegistration { binding.unregister() }
}
private fun refreshAudienceInternal(target: Player? = null, binding: AudienceBindingImpl? = null) {
runSync {
val players = target?.let { listOf(it) } ?: Bukkit.getOnlinePlayers()
val targets = binding?.let { listOf(it) } ?: audiences
if (players.isEmpty() || targets.isEmpty()) return@runSync
players.forEach { player ->
targets.forEach { it.evaluate(player) }
}
}
}
override fun refreshAudience(target: Player?) {
refreshAudienceInternal(target, null)
}
internal fun handleClick(event: PlayerInteractEntityEvent, clicked: Entity, surface: ClickSurface) {
val sortedHandlers = handlers.sortedBy { it.priority.ordinal }
if (sortedHandlers.isEmpty()) return
val context = DisplayClickContext(
player = event.player,
hand = event.hand,
clickedEntity = clicked,
surface = surface,
controller = this,
event = event
)
sortedHandlers.forEach { entry ->
entry.handler.handle(context)
if (event.isCancelled) return
}
}
internal fun handlePlayerQuit(player: Player) {
val uuid = player.uniqueId
viewerCounts.remove(uuid)
audiences.forEach { it.forget(uuid) }
}
private fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().runTask(plugin, action)
}
}
private fun <R> callSync(action: () -> R): R {
return if (Bukkit.isPrimaryThread()) {
action()
} else {
val future = CompletableFuture<R>()
Bukkit.getScheduler().runTask(plugin) { future.complete(action()) }
future.join()
}
}
private inner class AudienceBindingImpl(
private val predicate: AudiencePredicate
) {
private val activeViewers = ConcurrentHashMap.newKeySet<UUID>()
fun evaluate(player: Player) {
val uuid = player.uniqueId
val matches = runCatching { predicate.test(player) }.getOrDefault(false)
if (matches) {
if (activeViewers.add(uuid)) {
show(player)
}
} else {
if (activeViewers.remove(uuid)) {
hide(player)
}
}
}
fun forget(playerId: UUID) {
activeViewers.remove(playerId)
}
fun unregister() {
audiences.remove(this)
val targets = activeViewers.toList()
targets.mapNotNull { Bukkit.getPlayer(it) }.forEach { hide(it) }
activeViewers.clear()
}
fun clear() {
activeViewers.clear()
}
}
private data class HandlerEntry(
val priority: ClickPriority,
val handler: DisplayClickHandler
)
}

View File

@ -0,0 +1,84 @@
package net.hareworks.ghostdisplays.internal.controller
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import net.hareworks.ghostdisplays.api.click.ClickSurface
import org.bukkit.entity.Display
import org.bukkit.entity.Entity
import org.bukkit.entity.Interaction
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerChangedWorldEvent
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.event.player.PlayerRespawnEvent
internal class DisplayRegistry : Listener {
private val displayControllers = ConcurrentHashMap<UUID, BaseDisplayController<out Display>>()
private val interactionControllers = ConcurrentHashMap<UUID, BaseDisplayController<out Display>>()
fun register(controller: BaseDisplayController<out Display>) {
displayControllers[controller.display.uniqueId] = controller
controller.interaction?.let {
interactionControllers[it.uniqueId] = controller
}
}
fun unregister(controller: BaseDisplayController<out Display>) {
displayControllers.remove(controller.display.uniqueId, controller)
controller.interaction?.let {
interactionControllers.remove(it.uniqueId, controller)
}
}
fun controllerCount(): Int = displayControllers.size
fun shutdown() {
displayControllers.clear()
interactionControllers.clear()
}
@EventHandler
fun onPlayerInteractEntity(event: PlayerInteractEntityEvent) {
val entity = event.rightClicked
val controller = lookupController(entity) ?: return
val surface = if (entity is Interaction) ClickSurface.INTERACTION else ClickSurface.DISPLAY
controller.handleClick(event, entity, surface)
}
@EventHandler
fun onPlayerQuit(event: PlayerQuitEvent) {
controllersSnapshot().forEach { it.handlePlayerQuit(event.player) }
}
@EventHandler
fun onPlayerJoin(event: PlayerJoinEvent) {
refreshAudiences(event.player)
}
@EventHandler
fun onPlayerChangedWorld(event: PlayerChangedWorldEvent) {
refreshAudiences(event.player)
}
@EventHandler
fun onPlayerRespawn(event: PlayerRespawnEvent) {
refreshAudiences(event.player)
}
private fun lookupController(entity: Entity): BaseDisplayController<out Display>? {
return displayControllers[entity.uniqueId]
?: interactionControllers[entity.uniqueId]
}
private fun controllersSnapshot(): Collection<BaseDisplayController<out Display>> =
displayControllers.values.toSet()
private fun refreshAudiences(player: Player) {
val controllers = controllersSnapshot()
if (controllers.isEmpty()) return
controllers.forEach { it.refreshAudience(player) }
}
}

10
settings.gradle.kts Normal file
View File

@ -0,0 +1,10 @@
/*
* This file was generated by the Gradle 'init' task.
*
* The settings file is used to specify which projects to include in your build.
* 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"
includeBuild("kommand-lib")