From 4e33891ae57c9fe613ef3eb4b9a8784e9178589d Mon Sep 17 00:00:00 2001 From: Kam Date: Sat, 7 Mar 2026 18:26:41 -0500 Subject: [PATCH] Fixed / Improved Track Speed Handling on NPCs --- src/api | 2 +- .../client/renderer/RenderEnergyLaser.java | 14 ++-- .../controllers/data/ability/Ability.java | 37 ++++----- .../ability/type/energy/AbilityLaser.java | 2 +- .../npcs/entity/EntityAbilityLaser.java | 64 +++++++++++++++- src/main/java/noppes/npcs/DataAbilities.java | 76 +++++++++++++------ .../assets/customnpcs/lang/en_US.lang | 4 +- 7 files changed, 145 insertions(+), 54 deletions(-) diff --git a/src/api b/src/api index 6faa544f6..c46ca5068 160000 --- a/src/api +++ b/src/api @@ -1 +1 @@ -Subproject commit 6faa544f6f481690dfcbf55af130978a9402c35f +Subproject commit c46ca506830a012345d37bb3dccdec862fdf0734 diff --git a/src/main/java/kamkeel/npcs/client/renderer/RenderEnergyLaser.java b/src/main/java/kamkeel/npcs/client/renderer/RenderEnergyLaser.java index 1befc0dcc..469076d17 100644 --- a/src/main/java/kamkeel/npcs/client/renderer/RenderEnergyLaser.java +++ b/src/main/java/kamkeel/npcs/client/renderer/RenderEnergyLaser.java @@ -33,13 +33,13 @@ public class RenderEnergyLaser extends RenderEnergy { return; } - // Get laser parameters - double startX = laser.getStartX(); - double startY = laser.getStartY(); - double startZ = laser.getStartZ(); - double endX = laser.getEndX(); - double endY = laser.getEndY(); - double endZ = laser.getEndZ(); + // Get interpolated laser positions for smooth rendering between ticks + double startX = laser.getInterpolatedStartX(partialTicks); + double startY = laser.getInterpolatedStartY(partialTicks); + double startZ = laser.getInterpolatedStartZ(partialTicks); + double endX = laser.getInterpolatedEndX(partialTicks); + double endY = laser.getInterpolatedEndY(partialTicks); + double endZ = laser.getInterpolatedEndZ(partialTicks); float width = laser.getLaserWidth(); float alpha = 1.0f; diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java index 2ed1e54a1..0d62d9ae7 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java @@ -106,7 +106,7 @@ public abstract class Ability implements IAbility, IAbilityAction { protected LockMode lockMovement = LockMode.WINDUP; protected RotationMode rotationMode = RotationMode.FREE; protected LockMode rotationPhase = LockMode.WINDUP_AND_ACTIVE; - protected float trackDelay = 0; // 0 = instant tracking (legacy). Higher = slower lerp toward target. + protected float trackSpeed = 0; // 0 = instant tracking. Linear tracking speed in blocks/second. protected int windUpColor = 0x80FF4400; // Telegraph color during wind up protected int activeColor = 0xC0FF0000; // Telegraph warning/active color @@ -771,9 +771,9 @@ public abstract class Ability implements IAbility, IAbilityAction { .hover("ability.hover.rotationPhase") .visibleWhen(() -> this.rotationMode != RotationMode.FREE) ).tab("General")); - defs.add(FieldDef.floatField("ability.trackDelay", this::getTrackDelay, this::setTrackDelay) - .range(0.0f, 20.0f) - .hover("ability.hover.trackDelay") + defs.add(FieldDef.floatField("ability.trackSpeed", this::getTrackSpeed, this::setTrackSpeed) + .range(0.0f, 12.0f) + .hover("ability.hover.trackSpeed") .visibleWhen(() -> this.rotationMode == RotationMode.TRACK) .tab("General")); defs.add(FieldDef.row( @@ -1516,7 +1516,7 @@ public abstract class Ability implements IAbility, IAbilityAction { nbt.setInteger("lockMovement", lockMovement.ordinal()); nbt.setInteger("rotationMode", rotationMode.ordinal()); nbt.setInteger("rotationPhase", rotationPhase.ordinal()); - nbt.setFloat("trackDelay", trackDelay); + nbt.setFloat("trackSpeed", trackSpeed); nbt.setInteger("windUpColor", windUpColor); nbt.setInteger("activeColor", activeColor); nbt.setString("windUpSound", windUpSound); @@ -1613,7 +1613,7 @@ public abstract class Ability implements IAbility, IAbilityAction { lockMovement = LockMode.fromOrdinal(nbt.getInteger("lockMovement")); rotationMode = RotationMode.fromOrdinal(nbt.getInteger("rotationMode")); rotationPhase = LockMode.fromOrdinal(nbt.getInteger("rotationPhase")); - trackDelay = nbt.hasKey("trackDelay") ? nbt.getFloat("trackDelay") : 0; + trackSpeed = nbt.hasKey("trackSpeed") ? nbt.getFloat("trackSpeed") : 0; windUpColor = nbt.hasKey("windUpColor") ? nbt.getInteger("windUpColor") : 0x80FF4400; activeColor = nbt.hasKey("activeColor") ? nbt.getInteger("activeColor") : 0xC0FF0000; windUpSound = nbt.getString("windUpSound"); @@ -1958,14 +1958,15 @@ public abstract class Ability implements IAbility, IAbilityAction { this.rotationPhase = rotationPhase; } - public float getTrackDelay() { - return trackDelay; + public float getTrackSpeed() { + return trackSpeed; } - public void setTrackDelay(float trackDelay) { - this.trackDelay = trackDelay; + public void setTrackSpeed(float trackSpeed) { + this.trackSpeed = trackSpeed; } + /** * API method: Get lock movement type as integer. * @@ -2027,22 +2028,22 @@ public abstract class Ability implements IAbility, IAbilityAction { } /** - * API method: Get track delay for TRACK rotation mode. - * 0 = instant tracking (legacy). Higher = slower lerp toward target. + * API method: Get track speed for TRACK rotation mode (blocks/second). + * 0 = instant tracking. Comparable to player sprint speed (~5.6 blocks/sec). */ @Override - public float getTrackDelayValue() { - return trackDelay; + public float getTrackSpeedValue() { + return trackSpeed; } /** - * API method: Set track delay for TRACK rotation mode. + * API method: Set track speed for TRACK rotation mode (blocks/second). * - * @param delay 0 = instant tracking. Higher = slower lerp toward target. + * @param speed 0 = instant tracking. ~4 = dodgeable while sprinting. ~6 = barely undodgeable. */ @Override - public void setTrackDelayValue(float delay) { - this.trackDelay = Math.max(0, delay); + public void setTrackSpeedValue(float speed) { + this.trackSpeed = Math.max(0, speed); } /** diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityLaser.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityLaser.java index e9c4e0c16..40d30be43 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityLaser.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityLaser.java @@ -49,7 +49,7 @@ public class AbilityLaser extends AbilityEnergyProjectile im this.lockMovement = LockMode.WINDUP_AND_ACTIVE; this.rotationMode = RotationMode.TRACK; this.rotationPhase = LockMode.WINDUP_AND_ACTIVE; - this.trackDelay = 3; + this.trackSpeed = 4.0f; this.telegraphType = TelegraphType.LINE; this.showTelegraph = true; this.windUpAnimationName = "Ability_Laser_Windup"; diff --git a/src/main/java/kamkeel/npcs/entity/EntityAbilityLaser.java b/src/main/java/kamkeel/npcs/entity/EntityAbilityLaser.java index 2e39d3c49..e0a1a24d3 100644 --- a/src/main/java/kamkeel/npcs/entity/EntityAbilityLaser.java +++ b/src/main/java/kamkeel/npcs/entity/EntityAbilityLaser.java @@ -47,6 +47,10 @@ public class EntityAbilityLaser extends EntityEnergyProjectile { // End point for rendering private double endX, endY, endZ; + // Previous tick positions for partial tick interpolation + private double prevStartX, prevStartY, prevStartZ; + private double prevEndX, prevEndY, prevEndZ; + // Block hit deduplication — prevents event/explosion spam every tick private int lastBlockHitX = Integer.MIN_VALUE; private int lastBlockHitY = Integer.MIN_VALUE; @@ -107,10 +111,16 @@ public class EntityAbilityLaser extends EntityEnergyProjectile { } } - // Initialize end point + // Initialize end point and prev positions this.endX = x; this.endY = y; this.endZ = z; + this.prevStartX = x; + this.prevStartY = y; + this.prevStartZ = z; + this.prevEndX = x; + this.prevEndY = y; + this.prevEndZ = z; } @Override @@ -138,6 +148,14 @@ public class EntityAbilityLaser extends EntityEnergyProjectile { return; } + // Save previous positions for partial tick interpolation + prevStartX = startX; + prevStartY = startY; + prevStartZ = startZ; + prevEndX = endX; + prevEndY = endY; + prevEndZ = endZ; + // Origin/direction tracking runs on BOTH sides so the client can render the beam. // Once reflected, never resume owner-tracking — the laser flies in a fixed direction. if (!reflected) { @@ -285,6 +303,14 @@ public class EntityAbilityLaser extends EntityEnergyProjectile { this.endX = startX; this.endY = startY; this.endZ = startZ; + + // Initialize prev positions to current so first frame doesn't jump + this.prevStartX = startX; + this.prevStartY = startY; + this.prevStartZ = startZ; + this.prevEndX = startX; + this.prevEndY = startY; + this.prevEndZ = startZ; } private boolean setDirectionTowardTarget(EntityLivingBase target, double sourceX, double sourceY, double sourceZ) { @@ -733,6 +759,30 @@ public class EntityAbilityLaser extends EntityEnergyProjectile { return endZ; } + public double getInterpolatedStartX(float partialTicks) { + return prevStartX + (startX - prevStartX) * partialTicks; + } + + public double getInterpolatedStartY(float partialTicks) { + return prevStartY + (startY - prevStartY) * partialTicks; + } + + public double getInterpolatedStartZ(float partialTicks) { + return prevStartZ + (startZ - prevStartZ) * partialTicks; + } + + public double getInterpolatedEndX(float partialTicks) { + return prevEndX + (endX - prevEndX) * partialTicks; + } + + public double getInterpolatedEndY(float partialTicks) { + return prevEndY + (endY - prevEndY) * partialTicks; + } + + public double getInterpolatedEndZ(float partialTicks) { + return prevEndZ + (endZ - prevEndZ) * partialTicks; + } + public double getDirX() { return dirX; } @@ -761,6 +811,12 @@ public class EntityAbilityLaser extends EntityEnergyProjectile { this.endX = posX; this.endY = posY; this.endZ = posZ; + this.prevStartX = posX; + this.prevStartY = posY; + this.prevStartZ = posZ; + this.prevEndX = posX; + this.prevEndY = posY; + this.prevEndZ = posZ; } // ==================== REFLECTION SYNC ==================== @@ -862,6 +918,12 @@ public class EntityAbilityLaser extends EntityEnergyProjectile { this.endX = posX; this.endY = posY; this.endZ = posZ; + this.prevStartX = posX; + this.prevStartY = posY; + this.prevStartZ = posZ; + this.prevEndX = posX; + this.prevEndY = posY; + this.prevEndZ = posZ; // Precompute initial direction from owner look for launch. Vec3 look = owner == null ? null : owner.getLookVec(); diff --git a/src/main/java/noppes/npcs/DataAbilities.java b/src/main/java/noppes/npcs/DataAbilities.java index 497654677..74b8b057f 100644 --- a/src/main/java/noppes/npcs/DataAbilities.java +++ b/src/main/java/noppes/npcs/DataAbilities.java @@ -88,10 +88,11 @@ public class DataAbilities extends AbstractDataAbilities { */ private transient boolean hitScanActive = false; private transient EntityLivingBase hitScanTarget = null; - private transient float currentTrackDelay = 0; + private transient float currentTrackSpeed = 0; private transient float trackedYaw = 0; private transient float trackedPitch = 0; private transient boolean trackLerpedThisTick = false; + private transient boolean preserveTrackedRotation = false; /** * Bit flag for rotation control (LOCKED or TRACK) in data watcher slot 15 @@ -222,9 +223,15 @@ public class DataAbilities extends AbstractDataAbilities { @Override protected void onPreExecute(Ability ability, EntityLivingBase target) { - // Snap NPC to face target before execute so startMoving() reads correct rotation + // Face target before execute so startMoving() reads correct rotation + // Use trackSpeed so the NPC plays catchup rather than instant-snapping if (ability.isHitScanForCurrentPhase() && target != null) { - faceTarget(target); + float speed = ability.getTrackSpeed(); + if (speed <= 0) { + faceTarget(target); + } else { + faceTarget(target, speed); + } } } @@ -237,7 +244,7 @@ public class DataAbilities extends AbstractDataAbilities { // which runs AFTER super.onLivingUpdate() to override AI look helper if (ability != null && ability.isExecuting() && target != null) { if (ability.isHitScanForCurrentPhase()) { - enableHitScan(target, ability.getTrackDelay()); + enableHitScan(target, ability.getTrackSpeed()); } else { // Release hit scan if it was previously active if (hitScanActive) { @@ -260,7 +267,10 @@ public class DataAbilities extends AbstractDataAbilities { @Override protected void onBurstDelayReleaseLocks() { - if (hitScanActive) releaseRotationControl(); + if (hitScanActive) { + preserveTrackedRotation = true; + releaseRotationControl(); + } } @Override @@ -887,34 +897,42 @@ public class DataAbilities extends AbstractDataAbilities { /** * Enable hit scan tracking for the given target. */ - private void enableHitScan(EntityLivingBase target, float trackDelay) { + private void enableHitScan(EntityLivingBase target, float trackSpeed) { if (!hitScanActive) { hitScanActive = true; npc.setBoolFlag(true, ROTATION_CONTROLLED_FLAG); - // Capture current rotation as the starting point for lerp - trackedYaw = npc.rotationYawHead; - trackedPitch = npc.rotationPitch; + // Preserve tracked rotation across burst delays so the NPC doesn't snap + if (!preserveTrackedRotation) { + trackedYaw = npc.rotationYawHead; + trackedPitch = npc.rotationPitch; + } + preserveTrackedRotation = false; } hitScanTarget = target; - currentTrackDelay = trackDelay; + currentTrackSpeed = trackSpeed; } + private static final double DEG_TO_RAD = Math.PI / 180.0; + private static final double RAD_TO_DEG = 180.0 / Math.PI; + /** - * Face the target with optional track delay. - * When trackDelay <= 0, snaps instantly (legacy behavior). - * When trackDelay > 0, lerps toward the target each tick. + * Face the target with optional track speed (linear speed in blocks/second). + * When trackSpeed <= 0, snaps instantly. + * When trackSpeed > 0, the NPC's aim point moves at trackSpeed blocks/second, + * converted to angular velocity based on distance. This makes tracking feel + * consistent regardless of range — directly comparable to player movement speed. */ - private void faceTarget(EntityLivingBase target, float trackDelay) { + private void faceTarget(EntityLivingBase target, float trackSpeed) { double dx = target.posX - npc.posX; double dz = target.posZ - npc.posZ; double dy = (target.posY + target.getEyeHeight() * 0.5) - (npc.posY + npc.getEyeHeight()); double distXZ = Math.sqrt(dx * dx + dz * dz); - float targetYaw = (float) (Math.atan2(-dx, dz) * 180.0 / Math.PI); - float targetPitch = (float) (-(Math.atan2(dy, distXZ)) * 180.0 / Math.PI); + float targetYaw = (float) (Math.atan2(-dx, dz) * RAD_TO_DEG); + float targetPitch = (float) (-(Math.atan2(dy, distXZ)) * RAD_TO_DEG); - if (trackDelay <= 0) { - // Instant snap (legacy behavior) + if (trackSpeed <= 0) { + // Instant snap trackedYaw = targetYaw; trackedPitch = targetPitch; npc.rotationYaw = targetYaw; @@ -926,17 +944,27 @@ public class DataAbilities extends AbstractDataAbilities { npc.prevRenderYawOffset = targetYaw; npc.prevRotationPitch = targetPitch; } else { - // Only compute the lerp once per tick; subsequent calls just re-stamp values if (!trackLerpedThisTick) { trackLerpedThisTick = true; - float lerpFactor = 1.0f / (1.0f + trackDelay); - // Lerp from stored tracked rotation (not entity rotation, which AI may have overwritten) + // Convert linear speed (blocks/sec) to angular speed (degrees/tick) + // using the actual distance to the target + double dist = Math.max(1.0, Math.sqrt(dx * dx + dy * dy + dz * dz)); + float blocksPerTick = trackSpeed / 20.0f; + float maxAngularSpeed = (float) (Math.atan2(blocksPerTick, dist) * RAD_TO_DEG); + float yawDiff = wrapAngle(targetYaw - trackedYaw); float pitchDiff = targetPitch - trackedPitch; - trackedYaw = trackedYaw + yawDiff * lerpFactor; - trackedPitch = trackedPitch + pitchDiff * lerpFactor; + float totalAngle = (float) Math.sqrt(yawDiff * yawDiff + pitchDiff * pitchDiff); + if (totalAngle > maxAngularSpeed) { + float scale = maxAngularSpeed / totalAngle; + yawDiff *= scale; + pitchDiff *= scale; + } + + trackedYaw = trackedYaw + yawDiff; + trackedPitch = trackedPitch + pitchDiff; } // Set prev to current before updating (for smooth client interpolation) @@ -991,7 +1019,7 @@ public class DataAbilities extends AbstractDataAbilities { // Server: apply the appropriate rotation if (hitScanActive && hitScanTarget != null && !hitScanTarget.isDead) { - faceTarget(hitScanTarget, currentTrackDelay); + faceTarget(hitScanTarget, currentTrackSpeed); } else if (rotationLocked) { npc.rotationYaw = lockedYaw; npc.rotationYawHead = lockedYawHead; diff --git a/src/main/resources/assets/customnpcs/lang/en_US.lang b/src/main/resources/assets/customnpcs/lang/en_US.lang index a2e6b7d5c..913a3a257 100644 --- a/src/main/resources/assets/customnpcs/lang/en_US.lang +++ b/src/main/resources/assets/customnpcs/lang/en_US.lang @@ -2071,8 +2071,8 @@ ability.hover.weight=Higher weight = more likely to be chosen when multiple abil ability.hover.lockMovement=Prevents NPC pathfinding during the selected phases (Windup, Active, or Both) ability.hover.rotationMode=Free: normal AI rotation. Locked: freeze facing direction. Track: force face target for precise aiming. ability.hover.rotationPhase=When the rotation mode applies (Windup, Active, or Both) -ability.trackDelay=Track Delay -ability.hover.trackDelay=How sluggish tracking is. 0 = instant (impossible to dodge). Higher = slower rotation toward target. Default 3 for lasers. +ability.trackSpeed=Track Speed +ability.hover.trackSpeed=Tracking speed in blocks/sec. 0 = instant. Sprint speed is ~5.6. Default 4 for lasers (dodgeable while sprinting). ability.hover.interruptible=If enabled, ability can be interrupted by taking damage ability.hover.ignoreIFrames=Temporarily clears target hurt resistance before applying ability damage, then restores it. ability.hover.dazed=Stun duration in ticks if ability is interrupted (20 ticks = 1 second)