diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt b/src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt index aa3173a..1d3c421 100644 --- a/src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt +++ b/src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt @@ -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() diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/commands/MannequinCommands.kt b/src/main/kotlin/net/hareworks/npc-mannequin/commands/MannequinCommands.kt index 5b35cfc..0f68d64 100644 --- a/src/main/kotlin/net/hareworks/npc-mannequin/commands/MannequinCommands.kt +++ b/src/main/kotlin/net/hareworks/npc-mannequin/commands/MannequinCommands.kt @@ -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("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("id") + val input = argument("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("id") + val health = argument("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("id") + val payload = argument("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("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("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("id") - val input = argument("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("id") + registry.updateSettings(id) { it.copy(hideDescription = true) } + success("Description hidden for '$id'.") + } + } + literal("show") { + executes { + val id = argument("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("id") - val source = argument("source") - val stored = StoredProfile.from(source.playerProfile) + val name = argument("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("id") - val cmd = argument("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("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("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("id") + val seconds = argument("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("id") - val cmd = argument("command") - registry.updateSettings(id) { it.copy(playerCommand = cmd) } - success("Set player command for '$id'.") + val cmd = argument("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("id") + val msg = argument("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("id") - registry.updateSettings(id) { it.copy(playerCommand = null) } - success("Cleared player command for '$id'.") + val index = argument("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("id") + val i1 = argument("index1") + val i2 = argument("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("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("id") + val x = argument("x") + val y = argument("y") + val z = argument("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("id") + val radius = argument("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("id") + registry.updateSettings(id) { + it.copy(lookAtResetEnabled = true) + } + success("Enabled auto-turn for '$id'.") + } + } + literal("off") { + executes { + val id = argument("id") + registry.updateSettings(id) { + it.copy(lookAtResetEnabled = false) + } + success("Disabled auto-turn for '$id'.") + } + } + literal("wait") { + float("seconds") { + executes { + val id = argument("id") + val seconds = argument("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("id") + val seconds = argument("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("id") + val input = argument("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("id") + val input = argument("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("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'.") + } + } } } } diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/mannequin/MannequinSettings.kt b/src/main/kotlin/net/hareworks/npc-mannequin/mannequin/MannequinSettings.kt index 7b47d4a..add8bf8 100644 --- a/src/main/kotlin/net/hareworks/npc-mannequin/mannequin/MannequinSettings.kt +++ b/src/main/kotlin/net/hareworks/npc-mannequin/mannequin/MannequinSettings.kt @@ -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 = 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 = 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. */ diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinController.kt b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinController.kt index e6d89d5..23b041f 100644 --- a/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinController.kt +++ b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinController.kt @@ -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) diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinListener.kt b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinListener.kt index 41a3465..d46313a 100644 --- a/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinListener.kt +++ b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinListener.kt @@ -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.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.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.name) + ) + player.sendMessage(message) + } + } } - settings.playerCommand?.let { cmd -> - val cleanCmd = if (cmd.startsWith("/")) cmd.substring(1) else cmd - val finalCmd = cleanCmd.replace("", player.name) - player.performCommand(finalCmd) + // Handle Look-At-On-Click + if (settings.lookAtOnClick) { + registry.markInteraction(record.id, player) } } } diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinRegistry.kt b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinRegistry.kt index 135aabb..882a7b7 100644 --- a/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinRegistry.kt +++ b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinRegistry.kt @@ -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 = storage.load().toMutableMap() + + // Runtime state tracking for lookAtOnClick, not persisted + val interactions = java.util.concurrent.ConcurrentHashMap() + 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>() + + // Runtime state for look-at reset behavior + val lookAtStates = java.util.concurrent.ConcurrentHashMap() + + 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 = records.values.sortedBy { it.id } diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinTickTask.kt b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinTickTask.kt new file mode 100644 index 0000000..5d4fb78 --- /dev/null +++ b/src/main/kotlin/net/hareworks/npc-mannequin/service/MannequinTickTask.kt @@ -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) + } + } + } + } + } + } + } +} diff --git a/src/main/kotlin/net/hareworks/npc-mannequin/storage/MannequinStorage.kt b/src/main/kotlin/net/hareworks/npc-mannequin/storage/MannequinStorage.kt index 3641568..ad54cf9 100644 --- a/src/main/kotlin/net/hareworks/npc-mannequin/storage/MannequinStorage.kt +++ b/src/main/kotlin/net/hareworks/npc-mannequin/storage/MannequinStorage.kt @@ -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() + 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 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)