feat: 会話進行とauto-turn

This commit is contained in:
Keisuke Hirata 2025-12-09 07:19:57 +09:00
parent 9933aa7820
commit 0506ec8de3
8 changed files with 736 additions and 65 deletions

View File

@ -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()

View File

@ -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'.")
}
}
}
}
}

View File

@ -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.
*/

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -9,12 +9,74 @@ 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 }
fun find(id: String): MannequinRecord? = records[id]

View File

@ -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)
}
}
}
}
}
}
}
}

View File

@ -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)