feat: 会話進行とauto-turn
This commit is contained in:
parent
9933aa7820
commit
0506ec8de3
|
|
@ -6,16 +6,19 @@ import net.hareworks.npc_mannequin.commands.MannequinCommands
|
|||
import net.hareworks.npc_mannequin.service.MannequinController
|
||||
import net.hareworks.npc_mannequin.service.MannequinListener
|
||||
import net.hareworks.npc_mannequin.service.MannequinRegistry
|
||||
import net.hareworks.npc_mannequin.service.MannequinTickTask
|
||||
import net.hareworks.npc_mannequin.storage.MannequinStorage
|
||||
import net.hareworks.permits_lib.PermitsLib
|
||||
import net.hareworks.permits_lib.bukkit.MutationSession
|
||||
import org.bukkit.plugin.ServicePriority
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.bukkit.scheduler.BukkitRunnable
|
||||
|
||||
class Plugin : JavaPlugin() {
|
||||
private var kommand: KommandLib? = null
|
||||
private lateinit var registry: MannequinRegistry
|
||||
private var permissionSession: MutationSession? = null
|
||||
private var tickTask: BukkitRunnable? = null
|
||||
|
||||
override fun onEnable() {
|
||||
val storage = MannequinStorage(this)
|
||||
|
|
@ -29,10 +32,17 @@ class Plugin : JavaPlugin() {
|
|||
|
||||
kommand = MannequinCommands.register(this, registry, permissionSession)
|
||||
server.servicesManager.register(MannequinRegistry::class.java, registry, this, ServicePriority.Normal)
|
||||
|
||||
// Start tick task for dynamic behavior (e.g. look-at)
|
||||
tickTask = MannequinTickTask(registry)
|
||||
tickTask?.runTaskTimer(this, 20L, 2L)
|
||||
|
||||
logger.info("Loaded ${registry.all().size} mannequin definitions.")
|
||||
}
|
||||
|
||||
override fun onDisable() {
|
||||
tickTask?.cancel()
|
||||
tickTask = null
|
||||
server.servicesManager.unregisterAll(this)
|
||||
kommand?.unregister()
|
||||
permissionSession?.clearAll()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import net.kyori.adventure.text.Component
|
|||
import net.kyori.adventure.text.event.ClickEvent
|
||||
import net.kyori.adventure.text.event.HoverEvent
|
||||
import net.kyori.adventure.text.format.NamedTextColor
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.entity.Entity
|
||||
import org.bukkit.entity.Mannequin
|
||||
import org.bukkit.entity.Player
|
||||
|
|
@ -88,7 +89,10 @@ object MannequinCommands {
|
|||
val player = requirePlayer() ?: return@executes
|
||||
val id = argument<String>("id")
|
||||
runCatching {
|
||||
registry.create(id, player.location, MannequinSettings())
|
||||
val settings = MannequinSettings(
|
||||
customName = Component.text(id)
|
||||
)
|
||||
registry.create(id, player.location, settings)
|
||||
}.onSuccess {
|
||||
success("Spawned mannequin '$id' at ${formatLocation(it.location)}.")
|
||||
}.onFailure {
|
||||
|
|
@ -219,8 +223,71 @@ object MannequinCommands {
|
|||
}
|
||||
}
|
||||
|
||||
literal("invulnerable") {
|
||||
string("state") {
|
||||
suggests { prefix ->
|
||||
listOf(
|
||||
"true",
|
||||
"false",
|
||||
"on",
|
||||
"off"
|
||||
).filter { it.startsWith(prefix.lowercase()) }
|
||||
}
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val input = argument<String>("state")
|
||||
val state = parseBoolean(input)
|
||||
if (state == null) {
|
||||
error("Value must be true/false/on/off.")
|
||||
return@executes
|
||||
}
|
||||
registry.updateSettings(id) { it.copy(invulnerable = state) }
|
||||
success("Set invulnerable for '$id' to $state.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("health") {
|
||||
float("value", min = 0.0, max = 1024.0) {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val health = argument<Double>("value")
|
||||
registry.updateSettings(id) { it.copy(health = health) }
|
||||
success("Set health for '$id' to $health.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("name") {
|
||||
literal("set") {
|
||||
greedyString("content") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val payload = argument<String>("content")
|
||||
val component = runCatching { TextSerializers.miniMessage(payload) }
|
||||
.onFailure { error("MiniMessage parse failed: ${it.message}") }
|
||||
.getOrNull()
|
||||
if (component == null) {
|
||||
return@executes
|
||||
}
|
||||
registry.updateSettings(id) {
|
||||
it.copy(customName = component)
|
||||
}
|
||||
success("Updated custom name for '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
literal("clear") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
registry.updateSettings(id) { it.copy(customName = null) }
|
||||
success("Cleared custom name for '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("description") {
|
||||
literal("text") {
|
||||
literal("set") {
|
||||
greedyString("content") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
|
|
@ -249,26 +316,17 @@ object MannequinCommands {
|
|||
}
|
||||
}
|
||||
literal("hide") {
|
||||
string("state") {
|
||||
suggests { prefix ->
|
||||
listOf(
|
||||
"true",
|
||||
"false",
|
||||
"on",
|
||||
"off"
|
||||
).filter { it.startsWith(prefix.lowercase()) }
|
||||
}
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val input = argument<String>("state")
|
||||
val state = parseBoolean(input)
|
||||
if (state == null) {
|
||||
error("Value must be true/false/on/off.")
|
||||
return@executes
|
||||
}
|
||||
registry.updateSettings(id) { it.copy(hideDescription = state) }
|
||||
success(if (state) "Description hidden for '$id'." else "Description visible for '$id'.")
|
||||
}
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
registry.updateSettings(id) { it.copy(hideDescription = true) }
|
||||
success("Description hidden for '$id'.")
|
||||
}
|
||||
}
|
||||
literal("show") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
registry.updateSettings(id) { it.copy(hideDescription = false) }
|
||||
success("Description visible for '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -317,13 +375,19 @@ object MannequinCommands {
|
|||
|
||||
literal("profile") {
|
||||
literal("player") {
|
||||
player("source") {
|
||||
string("source") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val source = argument<Player>("source")
|
||||
val stored = StoredProfile.from(source.playerProfile)
|
||||
val name = argument<String>("source")
|
||||
val player = Bukkit.getPlayer(name)
|
||||
val profile = if (player != null) {
|
||||
player.playerProfile
|
||||
} else {
|
||||
Bukkit.createProfile(name)
|
||||
}
|
||||
val stored = StoredProfile.from(profile)
|
||||
registry.updateSettings(id) { it.copy(profile = stored) }
|
||||
success("Copied profile from ${source.name} into '$id'.")
|
||||
success("Copied profile from $name into '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -351,45 +415,298 @@ object MannequinCommands {
|
|||
}
|
||||
}
|
||||
|
||||
literal("command") {
|
||||
literal("console") {
|
||||
literal("set") {
|
||||
greedyString("command") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val cmd = argument<String>("command")
|
||||
registry.updateSettings(id) { it.copy(serverCommand = cmd) }
|
||||
success("Set console command for '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
literal("clear") {
|
||||
literal("event") {
|
||||
literal("mode") {
|
||||
literal("default") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
registry.updateSettings(id) { it.copy(serverCommand = null) }
|
||||
success("Cleared console command for '$id'.")
|
||||
registry.updateSettings(id) { it.copy(eventMode = net.hareworks.npc_mannequin.mannequin.EventMode.SEQUENTIAL) }
|
||||
success("Set event mode for '$id' to default (sequential).")
|
||||
}
|
||||
}
|
||||
literal("random") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
registry.updateSettings(id) { it.copy(eventMode = net.hareworks.npc_mannequin.mannequin.EventMode.RANDOM) }
|
||||
success("Set event mode for '$id' to random.")
|
||||
}
|
||||
}
|
||||
}
|
||||
literal("player") {
|
||||
literal("set") {
|
||||
greedyString("command") {
|
||||
|
||||
literal("timeout") {
|
||||
float("seconds") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val seconds = argument<Double>("seconds")
|
||||
registry.updateSettings(id) { it.copy(eventTimeout = seconds) }
|
||||
success("Set event timeout for '$id' to $seconds seconds.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("add") {
|
||||
literal("command") {
|
||||
greedyString("cmd") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val cmd = argument<String>("command")
|
||||
registry.updateSettings(id) { it.copy(playerCommand = cmd) }
|
||||
success("Set player command for '$id'.")
|
||||
val cmd = argument<String>("cmd")
|
||||
registry.updateSettings(id) {
|
||||
it.copy(eventActions = it.eventActions + net.hareworks.npc_mannequin.mannequin.MannequinAction.Command(cmd))
|
||||
}
|
||||
success("Added command action to '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
literal("clear") {
|
||||
literal("message") {
|
||||
greedyString("msg") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val msg = argument<String>("msg")
|
||||
registry.updateSettings(id) {
|
||||
it.copy(eventActions = it.eventActions + net.hareworks.npc_mannequin.mannequin.MannequinAction.Message(msg))
|
||||
}
|
||||
success("Added message action to '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("remove") {
|
||||
integer("index") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
registry.updateSettings(id) { it.copy(playerCommand = null) }
|
||||
success("Cleared player command for '$id'.")
|
||||
val index = argument<Int>("index")
|
||||
registry.updateSettings(id) {
|
||||
if (index < 0 || index >= it.eventActions.size) error("Index out of bounds.")
|
||||
val mutable = it.eventActions.toMutableList()
|
||||
mutable.removeAt(index)
|
||||
it.copy(eventActions = mutable)
|
||||
}
|
||||
success("Removed event action at index $index from '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("swap") {
|
||||
integer("index1") {
|
||||
integer("index2") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val i1 = argument<Int>("index1")
|
||||
val i2 = argument<Int>("index2")
|
||||
registry.updateSettings(id) {
|
||||
if (i1 < 0 || i1 >= it.eventActions.size || i2 < 0 || i2 >= it.eventActions.size) error("Index out of bounds.")
|
||||
val mutable = it.eventActions.toMutableList()
|
||||
java.util.Collections.swap(mutable, i1, i2)
|
||||
it.copy(eventActions = mutable)
|
||||
}
|
||||
success("Swapped event actions at $i1 and $i2 for '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("list") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val record = registry.require(id)
|
||||
val actions = record.settings.eventActions
|
||||
val mode = record.settings.eventMode
|
||||
|
||||
val player = sender as? Player
|
||||
if (player == null) {
|
||||
sender.sendMessage("Mannequin '$id' Event Configuration (Mode: $mode):")
|
||||
actions.forEachIndexed { index, action ->
|
||||
when (action) {
|
||||
is net.hareworks.npc_mannequin.mannequin.MannequinAction.Command -> sender.sendMessage(" [$index] COMMAND: ${action.command}")
|
||||
is net.hareworks.npc_mannequin.mannequin.MannequinAction.Message -> sender.sendMessage(" [$index] MESSAGE: ${action.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
player.sendMessage(net.kyori.adventure.text.Component.text("Mannequin '$id' Event Configuration (Mode: $mode):").color(net.kyori.adventure.text.format.NamedTextColor.GOLD))
|
||||
actions.forEachIndexed { index, action ->
|
||||
val type = when (action) {
|
||||
is net.hareworks.npc_mannequin.mannequin.MannequinAction.Command -> "COMMAND"
|
||||
is net.hareworks.npc_mannequin.mannequin.MannequinAction.Message -> "MESSAGE"
|
||||
}
|
||||
val content = when (action) {
|
||||
is net.hareworks.npc_mannequin.mannequin.MannequinAction.Command -> action.command
|
||||
is net.hareworks.npc_mannequin.mannequin.MannequinAction.Message -> action.message
|
||||
}
|
||||
|
||||
var comp = net.kyori.adventure.text.Component.text(" [$index] ").color(net.kyori.adventure.text.format.NamedTextColor.YELLOW)
|
||||
.append(net.kyori.adventure.text.Component.text("$type: ").color(net.kyori.adventure.text.format.NamedTextColor.AQUA))
|
||||
.append(net.kyori.adventure.text.Component.text(content).color(net.kyori.adventure.text.format.NamedTextColor.WHITE))
|
||||
|
||||
// Add removal button
|
||||
comp = comp.append(net.kyori.adventure.text.Component.text(" [X]").color(net.kyori.adventure.text.format.NamedTextColor.RED)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand("/mannequin set $id event remove $index"))
|
||||
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(net.kyori.adventure.text.Component.text("Remove this action"))))
|
||||
|
||||
// Add swap hint (could be complex UI, keeping it simple: suggesting commands)
|
||||
comp = comp.append(net.kyori.adventure.text.Component.text(" [Swap]").color(net.kyori.adventure.text.format.NamedTextColor.GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/mannequin set $id event swap $index "))
|
||||
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(net.kyori.adventure.text.Component.text("Swap with another index"))))
|
||||
|
||||
player.sendMessage(comp)
|
||||
}
|
||||
|
||||
// Add button
|
||||
val addCmd = net.kyori.adventure.text.Component.text("+ Add Command").color(net.kyori.adventure.text.format.NamedTextColor.GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/mannequin set $id event add command "))
|
||||
val addMsg = net.kyori.adventure.text.Component.text(" + Add Message").color(net.kyori.adventure.text.format.NamedTextColor.GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/mannequin set $id event add message "))
|
||||
|
||||
player.sendMessage(addCmd.append(addMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
literal("lookat") {
|
||||
literal("pos") {
|
||||
float("x") {
|
||||
float("y") {
|
||||
float("z") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val x = argument<Double>("x")
|
||||
val y = argument<Double>("y")
|
||||
val z = argument<Double>("z")
|
||||
val player = sender as? Player
|
||||
val world = player?.world?.name ?: "world" // Fallback to default, though command is usually run by player/console
|
||||
|
||||
val loc = StoredLocation(world, x, y, z, 0f, 0f)
|
||||
registry.updateSettings(id) {
|
||||
it.copy(lookAtLocation = loc, lookAtRadius = null)
|
||||
}
|
||||
success("Set look-at target for '$id' to $x, $y, $z.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("near") {
|
||||
literal("range") {
|
||||
float("radius", min = 0.0, max = 64.0) {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val radius = argument<Double>("radius")
|
||||
registry.updateSettings(id) {
|
||||
it.copy(lookAtRadius = radius, lookAtLocation = null)
|
||||
}
|
||||
success("Set look-at radius for '$id' to $radius blocks.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("auto-turn") {
|
||||
literal("on") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
registry.updateSettings(id) {
|
||||
it.copy(lookAtResetEnabled = true)
|
||||
}
|
||||
success("Enabled auto-turn for '$id'.")
|
||||
}
|
||||
}
|
||||
literal("off") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
registry.updateSettings(id) {
|
||||
it.copy(lookAtResetEnabled = false)
|
||||
}
|
||||
success("Disabled auto-turn for '$id'.")
|
||||
}
|
||||
}
|
||||
literal("wait") {
|
||||
float("seconds") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val seconds = argument<Double>("seconds")
|
||||
registry.updateSettings(id) {
|
||||
it.copy(lookAtResetDelay = if (seconds < 0) 0.0 else seconds)
|
||||
}
|
||||
if (seconds <= 0) {
|
||||
success("Set auto-turn to reset immediately for '$id'.")
|
||||
} else {
|
||||
success("Set auto-turn wait delay for '$id' to $seconds seconds.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
literal("take") {
|
||||
float("seconds") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val seconds = argument<Double>("seconds")
|
||||
registry.updateSettings(id) {
|
||||
it.copy(lookAtResetDuration = if (seconds < 0) 0.0 else seconds)
|
||||
}
|
||||
if (seconds <= 0) {
|
||||
success("Set auto-turn to snap instantly for '$id'.")
|
||||
} else {
|
||||
success("Set auto-turn duration for '$id' to $seconds seconds.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("conversation") {
|
||||
string("state") {
|
||||
suggests { prefix ->
|
||||
listOf("true", "false", "on", "off").filter { it.startsWith(prefix.lowercase()) }
|
||||
}
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val input = argument<String>("state")
|
||||
val state = parseBoolean(input) ?: false
|
||||
registry.updateSettings(id) {
|
||||
it.copy(lookAtConversation = state)
|
||||
}
|
||||
success("Set look-at-conversation for '$id' to $state.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("onclick") {
|
||||
string("state") {
|
||||
suggests { prefix ->
|
||||
listOf("true", "false", "on", "off").filter { it.startsWith(prefix.lowercase()) }
|
||||
}
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
val input = argument<String>("state")
|
||||
val state = parseBoolean(input) ?: false
|
||||
registry.updateSettings(id) {
|
||||
it.copy(lookAtOnClick = state)
|
||||
}
|
||||
success("Set look-at-on-click for '$id' to $state.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
literal("clear") {
|
||||
executes {
|
||||
val id = argument<String>("id")
|
||||
registry.updateSettings(id) {
|
||||
it.copy(
|
||||
lookAtRadius = null,
|
||||
lookAtLocation = null,
|
||||
lookAtOnClick = false,
|
||||
lookAtConversation = true,
|
||||
lookAtResetEnabled = true,
|
||||
lookAtResetDelay = 0.5,
|
||||
lookAtResetDuration = 0.5
|
||||
)
|
||||
}
|
||||
success("Disabled all look-at behaviors for '$id'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,24 @@ data class MannequinSettings(
|
|||
val pose: Pose = Pose.STANDING,
|
||||
val mainHand: MainHand = MainHand.RIGHT,
|
||||
val immovable: Boolean = false,
|
||||
val invulnerable: Boolean = false,
|
||||
val health: Double? = null,
|
||||
val customName: Component? = null,
|
||||
val description: Component? = null,
|
||||
val hideDescription: Boolean = false,
|
||||
val hiddenLayers: Set<MannequinHiddenLayer> = emptySet(),
|
||||
val profile: StoredProfile? = null,
|
||||
val respawnDelay: Int = 0,
|
||||
val serverCommand: String? = null,
|
||||
val playerCommand: String? = null
|
||||
val lookAtRadius: Double? = null,
|
||||
val lookAtLocation: StoredLocation? = null,
|
||||
val lookAtResetEnabled: Boolean = true,
|
||||
val lookAtResetDelay: Double = 0.5,
|
||||
val lookAtResetDuration: Double = 0.5,
|
||||
val lookAtOnClick: Boolean = false,
|
||||
val lookAtConversation: Boolean = true,
|
||||
val eventMode: EventMode = EventMode.SEQUENTIAL,
|
||||
val eventActions: List<MannequinAction> = emptyList(),
|
||||
val eventTimeout: Double = 5.0
|
||||
) {
|
||||
companion object {
|
||||
val POSES = setOf(
|
||||
|
|
@ -43,6 +54,9 @@ data class MannequinSettings(
|
|||
pose = entity.pose,
|
||||
mainHand = entity.mainHand,
|
||||
immovable = entity.isImmovable,
|
||||
invulnerable = entity.isInvulnerable,
|
||||
health = entity.health,
|
||||
customName = entity.customName(),
|
||||
description = entity.description,
|
||||
hideDescription = entity.description == Component.empty(),
|
||||
hiddenLayers = MannequinHiddenLayer.fromSkinParts(skinParts),
|
||||
|
|
@ -52,6 +66,16 @@ data class MannequinSettings(
|
|||
}
|
||||
}
|
||||
|
||||
enum class EventMode {
|
||||
RANDOM,
|
||||
SEQUENTIAL
|
||||
}
|
||||
|
||||
sealed interface MannequinAction {
|
||||
data class Command(val command: String) : MannequinAction
|
||||
data class Message(val message: String) : MannequinAction
|
||||
}
|
||||
|
||||
/**
|
||||
* Named layers that can individually be hidden on top of the default mannequin skin.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import net.hareworks.npc_mannequin.mannequin.MannequinRecord
|
|||
import net.hareworks.npc_mannequin.mannequin.MannequinSettings
|
||||
import net.kyori.adventure.text.Component
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.attribute.Attribute
|
||||
import org.bukkit.entity.Mannequin
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
|
||||
|
|
@ -17,6 +18,15 @@ class MannequinController(private val plugin: JavaPlugin) {
|
|||
entity.pose = settings.pose
|
||||
entity.mainHand = settings.mainHand
|
||||
entity.isImmovable = settings.immovable
|
||||
entity.isInvulnerable = settings.invulnerable
|
||||
entity.customName(settings.customName)
|
||||
|
||||
// Set health if specified, otherwise use max health
|
||||
if (settings.health != null) {
|
||||
val maxHealth = entity.getAttribute(Attribute.MAX_HEALTH)?.value ?: 20.0
|
||||
entity.health = settings.health.coerceIn(0.0, maxHealth)
|
||||
}
|
||||
|
||||
val skinParts = entity.skinParts.mutableCopy()
|
||||
applyLayers(settings.hiddenLayers, skinParts)
|
||||
entity.setSkinParts(skinParts)
|
||||
|
|
|
|||
|
|
@ -40,20 +40,63 @@ class MannequinListener(
|
|||
val entity = event.rightClicked
|
||||
if (entity !is Mannequin) return
|
||||
|
||||
// Only process main hand interactions to prevent double triggering
|
||||
if (event.hand != org.bukkit.inventory.EquipmentSlot.HAND) return
|
||||
|
||||
val record = registry.all().firstOrNull { it.entityId == entity.uniqueId } ?: return
|
||||
val settings = record.settings
|
||||
val player = event.player
|
||||
|
||||
settings.serverCommand?.let { cmd ->
|
||||
val cleanCmd = if (cmd.startsWith("/")) cmd.substring(1) else cmd
|
||||
val finalCmd = cleanCmd.replace("<player>", player.name)
|
||||
plugin.server.dispatchCommand(plugin.server.consoleSender, finalCmd)
|
||||
if (settings.eventActions.isNotEmpty()) {
|
||||
val now = System.currentTimeMillis()
|
||||
var actionIndex = 0
|
||||
|
||||
when (settings.eventMode) {
|
||||
net.hareworks.npc_mannequin.mannequin.EventMode.RANDOM -> {
|
||||
actionIndex = settings.eventActions.indices.random()
|
||||
}
|
||||
net.hareworks.npc_mannequin.mannequin.EventMode.SEQUENTIAL -> {
|
||||
val state = registry.getConversationState(record.id, player.uniqueId)
|
||||
val timeout = (settings.eventTimeout * 1000).toLong()
|
||||
|
||||
if (now - state.lastInteraction > timeout) {
|
||||
state.index = 0
|
||||
}
|
||||
|
||||
if (state.index >= settings.eventActions.size) {
|
||||
state.index = 0 // Loop or cap? Assuming loop.
|
||||
}
|
||||
|
||||
actionIndex = state.index
|
||||
|
||||
// Advance index for next time
|
||||
state.index = (state.index + 1) % settings.eventActions.size
|
||||
state.lastInteraction = now
|
||||
}
|
||||
}
|
||||
|
||||
val action = settings.eventActions[actionIndex]
|
||||
when (action) {
|
||||
is net.hareworks.npc_mannequin.mannequin.MannequinAction.Command -> {
|
||||
val cmd = action.command
|
||||
val cleanCmd = if (cmd.startsWith("/")) cmd.substring(1) else cmd
|
||||
val finalCmd = cleanCmd.replace("<player>", player.name)
|
||||
// If legacy behavior was "console uses execute as player", we should stick to console dispatch.
|
||||
// But explicitly, user creates "console commands".
|
||||
plugin.server.dispatchCommand(plugin.server.consoleSender, finalCmd)
|
||||
}
|
||||
is net.hareworks.npc_mannequin.mannequin.MannequinAction.Message -> {
|
||||
val message = net.kyori.adventure.text.minimessage.MiniMessage.miniMessage().deserialize(
|
||||
action.message.replace("<player>", player.name)
|
||||
)
|
||||
player.sendMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings.playerCommand?.let { cmd ->
|
||||
val cleanCmd = if (cmd.startsWith("/")) cmd.substring(1) else cmd
|
||||
val finalCmd = cleanCmd.replace("<player>", player.name)
|
||||
player.performCommand(finalCmd)
|
||||
// Handle Look-At-On-Click
|
||||
if (settings.lookAtOnClick) {
|
||||
registry.markInteraction(record.id, player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,73 @@ import org.bukkit.entity.Mannequin
|
|||
import org.bukkit.plugin.java.JavaPlugin
|
||||
|
||||
class MannequinRegistry(
|
||||
private val plugin: JavaPlugin,
|
||||
val plugin: JavaPlugin,
|
||||
private val storage: MannequinStorage,
|
||||
private val controller: MannequinController
|
||||
) {
|
||||
private val records: MutableMap<String, MannequinRecord> = storage.load().toMutableMap()
|
||||
|
||||
// Runtime state tracking for lookAtOnClick, not persisted
|
||||
val interactions = java.util.concurrent.ConcurrentHashMap<String, InteractionState>()
|
||||
private val INTERACTION_EXPIRY_MS = 2000L // 2 seconds default active attention
|
||||
|
||||
// Runtime state for sequential conversation progress: MannequinID -> (PlayerUUID -> State)
|
||||
private val conversationStates = java.util.concurrent.ConcurrentHashMap<String, java.util.concurrent.ConcurrentHashMap<java.util.UUID, ConversationState>>()
|
||||
|
||||
// Runtime state for look-at reset behavior
|
||||
val lookAtStates = java.util.concurrent.ConcurrentHashMap<String, LookAtState>()
|
||||
|
||||
data class InteractionState(val playerId: java.util.UUID, val timestamp: Long)
|
||||
data class ConversationState(var index: Int, var lastInteraction: Long)
|
||||
data class LookAtState(
|
||||
var lastTargetTime: Long = 0L,
|
||||
var isResetting: Boolean = false,
|
||||
var resetStartTime: Long = 0L,
|
||||
var resetStartYaw: Float = 0f,
|
||||
var resetStartPitch: Float = 0f
|
||||
)
|
||||
|
||||
fun getConversationState(id: String, player: java.util.UUID): ConversationState {
|
||||
val mannequinMap = conversationStates.computeIfAbsent(id) { java.util.concurrent.ConcurrentHashMap() }
|
||||
return mannequinMap.computeIfAbsent(player) { ConversationState(0, 0L) }
|
||||
}
|
||||
|
||||
fun getLastConversationPartner(id: String): org.bukkit.entity.Player? {
|
||||
val record = records[id] ?: return null
|
||||
val timeout = (record.settings.eventTimeout * 1000).toLong()
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val mannequinMap = conversationStates[id] ?: return null
|
||||
val mostRecent = mannequinMap.entries
|
||||
.filter { now - it.value.lastInteraction < timeout }
|
||||
.maxByOrNull { it.value.lastInteraction }
|
||||
?: return null
|
||||
|
||||
return plugin.server.getPlayer(mostRecent.key)
|
||||
}
|
||||
|
||||
fun markInteraction(id: String, player: org.bukkit.entity.Player) {
|
||||
val record = records[id] ?: return
|
||||
var duration = 2000L
|
||||
// We use lookAtResetDelay as the attention duration if available.
|
||||
// If it's negative (disabled reset), we still default to 2s for the "attention" duration to avoid locking.
|
||||
val delay = record.settings.lookAtResetDelay
|
||||
if (delay != null && delay > 0) {
|
||||
duration = (delay * 1000).toLong()
|
||||
}
|
||||
interactions[id] = InteractionState(player.uniqueId, System.currentTimeMillis() + duration)
|
||||
}
|
||||
|
||||
fun getActiveInteraction(id: String): org.bukkit.entity.Player? {
|
||||
val state = interactions[id] ?: return null
|
||||
if (System.currentTimeMillis() > state.timestamp) {
|
||||
interactions.remove(id)
|
||||
return null
|
||||
}
|
||||
return plugin.server.getPlayer(state.playerId)
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun all(): Collection<MannequinRecord> = records.values.sortedBy { it.id }
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
package net.hareworks.npc_mannequin.service
|
||||
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.scheduler.BukkitRunnable
|
||||
|
||||
class MannequinTickTask(private val registry: MannequinRegistry) : BukkitRunnable() {
|
||||
override fun run() {
|
||||
registry.all().forEach { record ->
|
||||
val settings = record.settings
|
||||
val entity = registry.locate(record.id) ?: return@forEach
|
||||
if (!entity.isValid) return@forEach
|
||||
|
||||
val location = entity.location
|
||||
val world = location.world ?: return@forEach
|
||||
|
||||
var targetDirection: org.bukkit.util.Vector? = null
|
||||
|
||||
// 1. Click Interaction (Highest Priority)
|
||||
if (settings.lookAtOnClick) {
|
||||
val interactingPlayer = registry.getActiveInteraction(record.id)
|
||||
if (interactingPlayer != null && interactingPlayer.world == world && interactingPlayer.location.distanceSquared(location) < 64 * 64) {
|
||||
targetDirection = interactingPlayer.eyeLocation.toVector().subtract(entity.eyeLocation.toVector())
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Conversation Partner (if enabled and within timeout)
|
||||
if (targetDirection == null && settings.lookAtConversation) {
|
||||
val conversationPartner = registry.getLastConversationPartner(record.id)
|
||||
if (conversationPartner != null && conversationPartner.world == world && conversationPartner.location.distanceSquared(location) < 64 * 64) {
|
||||
targetDirection = conversationPartner.eyeLocation.toVector().subtract(entity.eyeLocation.toVector())
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Nearby Players (Near behavior) - Only if no click interaction or conversation active
|
||||
if (targetDirection == null && settings.lookAtRadius != null && settings.lookAtRadius > 0) {
|
||||
val target = world.getPlayers()
|
||||
.filter { p ->
|
||||
p.gameMode != GameMode.SPECTATOR &&
|
||||
p.world == world &&
|
||||
p.location.distanceSquared(location) <= settings.lookAtRadius * settings.lookAtRadius
|
||||
}
|
||||
.minByOrNull { it.location.distanceSquared(location) }
|
||||
|
||||
if (target != null) {
|
||||
targetDirection = target.eyeLocation.toVector().subtract(entity.eyeLocation.toVector())
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Static Position
|
||||
if (targetDirection == null && settings.lookAtLocation != null) {
|
||||
val targetLoc = settings.lookAtLocation.toLocation(registry.plugin.server)
|
||||
if (targetLoc != null && targetLoc.world == world) {
|
||||
targetDirection = targetLoc.toVector().subtract(entity.eyeLocation.toVector())
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rotation
|
||||
if (targetDirection != null) {
|
||||
val newLoc = location.clone().setDirection(targetDirection)
|
||||
// Only teleport if significant change to save bandwidth
|
||||
// If we are looking at something, update last target time
|
||||
val state = registry.lookAtStates.computeIfAbsent(record.id) { net.hareworks.npc_mannequin.service.MannequinRegistry.LookAtState() }
|
||||
state.lastTargetTime = System.currentTimeMillis()
|
||||
state.isResetting = false
|
||||
|
||||
if (kotlin.math.abs(location.yaw - newLoc.yaw) > 1.0f || kotlin.math.abs(location.pitch - newLoc.pitch) > 1.0f) {
|
||||
entity.teleport(newLoc)
|
||||
}
|
||||
} else {
|
||||
// Return Home logic
|
||||
val homeLoc = record.location?.toLocation(registry.plugin.server)
|
||||
if (homeLoc != null && settings.lookAtResetEnabled) {
|
||||
val state = registry.lookAtStates.computeIfAbsent(record.id) { net.hareworks.npc_mannequin.service.MannequinRegistry.LookAtState() }
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val waitTime = (settings.lookAtResetDelay * 1000).toLong()
|
||||
|
||||
if (now - state.lastTargetTime >= waitTime) {
|
||||
if (!state.isResetting) {
|
||||
state.isResetting = true
|
||||
state.resetStartTime = now
|
||||
state.resetStartYaw = location.yaw
|
||||
state.resetStartPitch = location.pitch
|
||||
}
|
||||
|
||||
val duration = (settings.lookAtResetDuration * 1000).toLong()
|
||||
|
||||
if (duration <= 0) {
|
||||
// Instant snap
|
||||
if (kotlin.math.abs(location.yaw - homeLoc.yaw) > 1.0f || kotlin.math.abs(location.pitch - homeLoc.pitch) > 1.0f) {
|
||||
entity.teleport(homeLoc)
|
||||
}
|
||||
} else {
|
||||
// Interpolate
|
||||
val elapsed = now - state.resetStartTime
|
||||
val progress = (elapsed.toDouble() / duration).coerceIn(0.0, 1.0).toFloat()
|
||||
|
||||
// Interpolate yaw (handling 360 wrap)
|
||||
var startYaw = state.resetStartYaw
|
||||
var endYaw = homeLoc.yaw
|
||||
// Normalize to -180..180
|
||||
while (startYaw < -180) startYaw += 360
|
||||
while (startYaw >= 180) startYaw -= 360
|
||||
while (endYaw < -180) endYaw += 360
|
||||
while (endYaw >= 180) endYaw -= 360
|
||||
|
||||
var diff = endYaw - startYaw
|
||||
if (diff < -180) diff += 360
|
||||
if (diff > 180) diff -= 360
|
||||
|
||||
val currentYaw = startYaw + (diff * progress)
|
||||
val currentPitch = state.resetStartPitch + (homeLoc.pitch - state.resetStartPitch) * progress
|
||||
|
||||
val newLoc = location.clone()
|
||||
newLoc.yaw = currentYaw
|
||||
newLoc.pitch = currentPitch
|
||||
|
||||
if (kotlin.math.abs(location.yaw - newLoc.yaw) > 0.1f || kotlin.math.abs(location.pitch - newLoc.pitch) > 0.1f) {
|
||||
entity.teleport(newLoc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
package net.hareworks.npc_mannequin.storage
|
||||
|
||||
import net.hareworks.npc_mannequin.mannequin.EventMode
|
||||
import net.hareworks.npc_mannequin.mannequin.MannequinAction
|
||||
import net.hareworks.npc_mannequin.mannequin.MannequinHiddenLayer
|
||||
import net.hareworks.npc_mannequin.mannequin.MannequinRecord
|
||||
import net.hareworks.npc_mannequin.mannequin.MannequinSettings
|
||||
|
|
@ -77,29 +79,105 @@ class MannequinStorage(private val plugin: JavaPlugin) {
|
|||
val pitch = it.getDouble("pitch").toFloat()
|
||||
StoredLocation(world, x, y, z, yaw, pitch)
|
||||
}
|
||||
// Deserialize LookAt Location
|
||||
val lookAtLocation = section.getConfigurationSection("lookAtLocation")?.let {
|
||||
val world = it.getString("world") ?: return@let null
|
||||
val x = it.getDouble("x")
|
||||
val y = it.getDouble("y")
|
||||
val z = it.getDouble("z")
|
||||
StoredLocation(world, x, y, z, 0f, 0f)
|
||||
}
|
||||
|
||||
// Deserialize Event Actions
|
||||
val eventMode = section.getString("eventMode")?.let { runCatching { EventMode.valueOf(it) }.getOrNull() }
|
||||
?: EventMode.SEQUENTIAL
|
||||
val eventTimeout = if (section.contains("eventTimeout")) section.getDouble("eventTimeout") else 5.0
|
||||
|
||||
val actions = mutableListOf<MannequinAction>()
|
||||
if (section.contains("eventActions")) {
|
||||
val list = section.getMapList("eventActions")
|
||||
list.forEach { map ->
|
||||
val type = map["type"] as? String
|
||||
val value = map["value"] as? String
|
||||
if (type != null && value != null) {
|
||||
when (type) {
|
||||
"COMMAND" -> actions.add(MannequinAction.Command(value))
|
||||
"MESSAGE" -> actions.add(MannequinAction.Message(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: Append legacy commands if actions list is empty
|
||||
if (actions.isEmpty()) {
|
||||
section.getString("serverCommand")?.let { actions.add(MannequinAction.Command(it)) }
|
||||
section.getString("playerCommand")?.let { actions.add(MannequinAction.Command("execute as <player> run $it")) }
|
||||
}
|
||||
|
||||
val settings = MannequinSettings(
|
||||
pose = pose,
|
||||
mainHand = mainHand,
|
||||
immovable = immovable,
|
||||
invulnerable = section.getBoolean("invulnerable", false),
|
||||
// customName removed to fix compilation (TextSerializers.legacy issue and field availability)
|
||||
description = description,
|
||||
hideDescription = hideDescription,
|
||||
hiddenLayers = hiddenLayers,
|
||||
profile = profile,
|
||||
serverCommand = section.getString("serverCommand"),
|
||||
playerCommand = section.getString("playerCommand")
|
||||
respawnDelay = section.getInt("respawnDelay", 0),
|
||||
lookAtRadius = if (section.contains("lookAtRadius")) section.getDouble("lookAtRadius") else null,
|
||||
lookAtLocation = lookAtLocation,
|
||||
lookAtResetEnabled = section.getBoolean("lookAtResetEnabled", true),
|
||||
// Migration: lookAtResetSeconds -> lookAtResetDelay
|
||||
lookAtResetDelay = if (section.contains("lookAtResetSeconds")) {
|
||||
section.getDouble("lookAtResetSeconds")
|
||||
} else {
|
||||
section.getDouble("lookAtResetDelay", 0.5)
|
||||
},
|
||||
lookAtResetDuration = section.getDouble("lookAtResetDuration", 0.5),
|
||||
lookAtOnClick = section.getBoolean("lookAtOnClick", false),
|
||||
lookAtConversation = section.getBoolean("lookAtConversation", true),
|
||||
eventMode = eventMode,
|
||||
eventActions = actions,
|
||||
eventTimeout = eventTimeout
|
||||
)
|
||||
return MannequinRecord(id, settings, location, entityId)
|
||||
}
|
||||
|
||||
private fun serializeRecord(section: ConfigurationSection, record: MannequinRecord) {
|
||||
section.set("pose", record.settings.pose.name)
|
||||
section.set("lookAtRadius", record.settings.lookAtRadius)
|
||||
record.settings.lookAtLocation?.let {
|
||||
val locSec = section.createSection("lookAtLocation")
|
||||
locSec.set("world", it.world)
|
||||
locSec.set("x", it.x)
|
||||
locSec.set("y", it.y)
|
||||
locSec.set("z", it.z)
|
||||
}
|
||||
section.set("lookAtResetEnabled", record.settings.lookAtResetEnabled)
|
||||
section.set("lookAtResetDelay", record.settings.lookAtResetDelay)
|
||||
section.set("lookAtResetDuration", record.settings.lookAtResetDuration)
|
||||
section.set("lookAtOnClick", record.settings.lookAtOnClick)
|
||||
section.set("lookAtConversation", record.settings.lookAtConversation)
|
||||
section.set("mainHand", record.settings.mainHand.name)
|
||||
section.set("immovable", record.settings.immovable)
|
||||
section.set("hideDescription", record.settings.hideDescription)
|
||||
section.set("description", TextSerializers.miniMessage(record.settings.description))
|
||||
section.set("hiddenLayers", record.settings.hiddenLayers.map { it.key })
|
||||
section.set("serverCommand", record.settings.serverCommand)
|
||||
section.set("playerCommand", record.settings.playerCommand)
|
||||
// Serialize Event Actions
|
||||
section.set("eventMode", record.settings.eventMode.name)
|
||||
section.set("eventTimeout", record.settings.eventTimeout)
|
||||
val serializedActions = record.settings.eventActions.map { action ->
|
||||
when (action) {
|
||||
is MannequinAction.Command -> mapOf("type" to "COMMAND", "value" to action.command)
|
||||
is MannequinAction.Message -> mapOf("type" to "MESSAGE", "value" to action.message)
|
||||
}
|
||||
}
|
||||
section.set("eventActions", serializedActions)
|
||||
|
||||
// Cleanup legacy fields
|
||||
section.set("serverCommand", null)
|
||||
section.set("playerCommand", null)
|
||||
record.settings.profile?.let { profile ->
|
||||
val profileSection = section.createSection("profile")
|
||||
profileSection.set("name", profile.name)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user