diff --git a/build.gradle.kts b/build.gradle.kts index 2a46194..244f034 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,7 @@ repositories { maven("https://repo.lunarclient.dev") maven("https://maven.enginehub.org/repo/") maven("https://repo.citizensnpcs.co/") + maven("https://repo.alessiodp.com/releases/") } dependencies { @@ -41,7 +42,7 @@ dependencies { compileOnly("io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT") compileOnly("com.sk89q.worldedit:worldedit-core:7.2.17-SNAPSHOT") compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.2.17-SNAPSHOT") - compileOnly("net.citizensnpcs:citizens-main:2.0.36-SNAPSHOT") + compileOnly("net.citizensnpcs:citizens-main:2.0.35-SNAPSHOT") } tasks { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TricksterKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TricksterKit.kt index 66af02b..e8ed322 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TricksterKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TricksterKit.kt @@ -106,6 +106,7 @@ class TricksterKit : Kit(), Listener data class DecoySession( val npc: NPC, var expiryTask: BukkitTask, + val chaseTask: BukkitTask, val activatedAt: Long, val playstyle: Playstyle, val tricksterUUID: UUID @@ -134,6 +135,12 @@ class TricksterKit : Kit(), Listener /** 3 seconds in ticks — Slowness I on explosion victims. */ const val SLOWNESS_DURATION_TICKS = 60 + + /** How often (in ticks) the NPC re-targets the nearest enemy. */ + const val NPC_CHASE_UPDATE_TICKS = 10L + + /** Walk speed passed to the Citizens navigator. 0.5 = roughly player walking pace. */ + const val NPC_WALK_SPEED = 0.5 } // ── Live config accessors ───────────────────────────────────────────────── @@ -162,6 +169,12 @@ class TricksterKit : Kit(), Listener private val slownessDurationTicks: Int get() = override().getInt( "slowness_duration_ticks" ) ?: SLOWNESS_DURATION_TICKS + private val npcChaseUpdateTicks: Long + get() = override().getLong( "npc_chase_update_ticks" ) ?: NPC_CHASE_UPDATE_TICKS + + private val npcWalkSpeed: Double + get() = override().getDouble( "npc_walk_speed" ) ?: NPC_WALK_SPEED + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AggressiveActive() @@ -226,7 +239,7 @@ class TricksterKit : Kit(), Listener player: Player ) { cooldowns.remove( player.uniqueId ) - terminateDecoy( player.uniqueId, silent = true ) + terminateDecoy( player.uniqueId) cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) } } @@ -263,7 +276,7 @@ class TricksterKit : Kit(), Listener if ( !plugin.gameManager.alivePlayers.contains( attacker.uniqueId ) ) return val trickster = Bukkit.getPlayer( session.tricksterUUID ) ?: run { - terminateDecoy( session.tricksterUUID, silent = true ) + terminateDecoy( session.tricksterUUID) return } @@ -296,11 +309,30 @@ class TricksterKit : Kit(), Listener val registry = CitizensAPI.getNPCRegistry() val npc = registry.createNPC( EntityType.PLAYER, player.name ) - // Mirror the player's skin by name — Citizens fetches it from Mojang - val skinTrait = npc.getOrAddTrait( SkinTrait::class.java ) - skinTrait.setSkinName( player.name, false ) + // ── Skin: direkt aus dem gecachten Paper-PlayerProfile lesen ────────── + // Paper speichert die Textur-Property des letzten bekannten Mojang-Profils + // im PlayerProfile — kein Netzwerkaufruf nötig. + val profile = player.playerProfile + val textureProperty = profile.properties + .firstOrNull { it.name == "textures" } - // Mirror the player's worn armour for visual authenticity + val skinTrait = npc.getOrAddTrait( SkinTrait::class.java ) + + if ( textureProperty != null ) + { + // Synchron setzen — kein Mojang-Lookup, erscheint sofort korrekt + skinTrait.setTexture( + textureProperty.value, + textureProperty.signature ?: "" + ) + } + else + { + // Fallback: name-basierter Lookup (nur wenn Paper-Profil leer ist) + skinTrait.setSkinName( player.name, false ) + } + + // ── Rüstung spiegeln ────────────────────────────────────────────────── val equipment = npc.getOrAddTrait( Equipment::class.java ) equipment.set( EquipmentSlot.HELMET, player.inventory.helmet ?: ItemStack( Material.AIR ) ) equipment.set( EquipmentSlot.CHESTPLATE, player.inventory.chestplate ?: ItemStack( Material.AIR ) ) @@ -309,29 +341,82 @@ class TricksterKit : Kit(), Listener npc.spawn( location ) - val now = System.currentTimeMillis() + val now = System.currentTimeMillis() + val tricksterUUID = player.uniqueId + + // ── Chase-Task: NPC läuft auf nächsten Feind zu ─────────────────────── + val chaseTask = startNPCChaseTask( npc, tricksterUUID ) + + // ── Expiry-Task ─────────────────────────────────────────────────────── + val expiryTask = Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( activeDecoys.containsKey( tricksterUUID ) ) + onDecoyExpired( player, activeDecoys[ tricksterUUID ]!! ) + }, decoyDurationTicks.toLong() ) - // Placeholder task — replaced immediately below; needed for data class construction val session = DecoySession( npc = npc, - expiryTask = Bukkit.getScheduler().runTaskLater( plugin, { -> }, 1L ), + expiryTask = expiryTask, + chaseTask = chaseTask, activatedAt = now, playstyle = playstyle, - tricksterUUID = player.uniqueId + tricksterUUID = tricksterUUID ) - // Replace placeholder with the real expiry task - session.expiryTask.cancel() - val capturedDurationTicks = decoyDurationTicks.toLong() + activeDecoys[ tricksterUUID ] = session + return session + } - val expiryTask = Bukkit.getScheduler().runTaskLater( plugin, { -> - if ( !activeDecoys.containsKey( player.uniqueId ) ) return@runTaskLater - onDecoyExpired( player, session ) - }, capturedDurationTicks ) + /** + * Starts a repeating task that steers the Citizens NPC toward the nearest + * alive enemy (excluding the Trickster themselves) every [NPC_CHASE_UPDATE_TICKS]. + * + * Citizens' navigator is only available after the NPC is spawned, so the task + * reads `npc.navigator` safely on the main thread each iteration. + * + * The task cancels itself if the NPC is no longer spawned or if the session + * has already been removed from [activeDecoys]. + */ + private fun startNPCChaseTask( + npc: NPC, + tricksterUUID: UUID + ): BukkitTask + { + return Bukkit.getScheduler().runTaskTimer( plugin, { -> - val finalSession = session.copy( expiryTask = expiryTask ) - activeDecoys[ player.uniqueId ] = finalSession - return finalSession + // Self-cancel if the session is gone or the NPC was destroyed + if ( !activeDecoys.containsKey( tricksterUUID ) || !npc.isSpawned ) + { + // Cancellation happens via the task reference stored in DecoySession; + // we can't call cancel() on ourselves here without storing a ref first, + // so we rely on terminateDecoy() which cancels the task externally. + return@runTaskTimer + } + + val npcEntity = npc.entity ?: return@runTaskTimer + val npcLoc = npcEntity.location + + // Find the nearest alive player that is not the Trickster + val target = plugin.gameManager.alivePlayers + .asSequence() + .filter { it != tricksterUUID } + .mapNotNull { Bukkit.getPlayer( it ) } + .filter { it.world == npcLoc.world } + .minByOrNull { it.location.distanceSquared( npcLoc ) } + + if ( target == null ) + { + npc.navigator.cancelNavigation() + return@runTaskTimer + } + + // Update navigator destination — Citizens handles pathfinding internally + val params = npc.navigator.localParameters + params.speedModifier( npcWalkSpeed.toFloat() ) + params.range( 64f ) + + npc.navigator.setTarget( target, false ) + + }, 0L, npcChaseUpdateTicks ) } /** @@ -389,14 +474,13 @@ class TricksterKit : Kit(), Listener * Removes the active decoy for [tricksterUUID] without any gameplay effect. * Used in [onRemove] and emergency cleanup paths. * - * @param silent If true, no ActionBar is sent to the player. */ private fun terminateDecoy( - tricksterUUID: UUID, - silent: Boolean + tricksterUUID: UUID ) { val session = activeDecoys.remove( tricksterUUID ) ?: return session.expiryTask.cancel() + session.chaseTask.cancel() destroyNPC( session.npc ) } @@ -413,7 +497,7 @@ class TricksterKit : Kit(), Listener ) { val npcLoc = session.npc.storedLocation ?: trickster.location - terminateDecoy( trickster.uniqueId, silent = true ) + terminateDecoy( trickster.uniqueId) triggerFakeExplosion( trickster, npcLoc ) @@ -431,7 +515,7 @@ class TricksterKit : Kit(), Listener trickster: Player, session: DecoySession ) { - terminateDecoy( trickster.uniqueId, silent = true ) + terminateDecoy( trickster.uniqueId) // Cooldown reduction: shift lastUse forward so remaining wait is halved. // If the full cooldown is 25 s and 3 s have elapsed, the player would normally