Refactor movement abilities: extract shared logic into AbilityMovement class for Charge and Dash abilities

This commit is contained in:
Kam committed 2026-02-15 22:04:47 -05:00
1 parent 3beaf68815
commit 1fb52a4ae9
3 files changed
+316 -203

No files matched your search

@@ -2,7 +2,6 @@ package kamkeel.npcs.controllers.data.ability.type;
import cpw.mods.fml.relauncher.Side;
import cpw.mods.fml.relauncher.SideOnly;
import kamkeel.npcs.controllers.data.ability.Ability;
import kamkeel.npcs.controllers.data.ability.LockMovementType;
import kamkeel.npcs.controllers.data.ability.TargetingMode;
import kamkeel.npcs.controllers.data.telegraph.Telegraph;
@@ -12,8 +11,6 @@ import net.minecraft.entity.Entity;
import net.minecraft.entity.EntityLivingBase;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.util.AxisAlignedBB;
import net.minecraft.util.Vec3;
import noppes.npcs.entity.EntityNPCInterface;
import noppes.npcs.client.gui.builder.FieldDef;
import kamkeel.npcs.controllers.data.ability.gui.AbilityFieldDefs;
@@ -25,9 +22,10 @@ import java.util.List;
import java.util.Set;
/**
* Charge ability: Rush attack where NPC charges in a line, damaging all targets hit.
* Charge ability: Rush attack where the caster charges in a line, damaging all targets hit.
* Extends AbilityMovement for shared direction locking, stall detection, and velocity application.
*/
public class AbilityCharge extends Ability implements IAbilityCharge {
public class AbilityCharge extends AbilityMovement implements IAbilityCharge {
// Type-specific parameters
private float chargeSpeed = 0.8f;
@@ -35,13 +33,8 @@ public class AbilityCharge extends Ability implements IAbilityCharge {
private float knockback = 3.0f;
private float hitWidth = 1.5f;
// Runtime state (transient)
private transient double startX, startY, startZ;
private transient double prevTickX, prevTickZ;
private transient Vec3 chargeDirection;
// Type-specific runtime state
private transient Set<Integer> hitEntities = new HashSet<>();
private transient float lockedYaw;
private transient int maxActiveTicks;
public AbilityCharge() {
this.typeId = "ability.cnpc.charge";
@@ -71,138 +64,68 @@ public class AbilityCharge extends Ability implements IAbilityCharge {
return new TargetingMode[]{TargetingMode.AGGRO_TARGET};
}
@Override
public boolean hasAbilityMovement() {
return true; // This ability moves the NPC
}
/**
* Called on the first tick of windup - lock direction here so it matches telegraph.
*/
@Override
public void onWindUpTick(EntityLivingBase caster, EntityLivingBase target, int tick) {
if (tick == 1) {
// Lock direction on first windup tick (same time telegraph is created)
lockChargeDirection(caster, target);
lockDirection(caster, target);
}
// Keep caster facing the locked direction during windup
enforceLockedRotation(caster);
}
/**
* Locks the charge direction. Called once at windup start — direction won't change even if target moves.
* NPC: charges toward aggro target.
* Player: charges in look direction.
*/
private void lockChargeDirection(EntityLivingBase caster, EntityLivingBase target) {
if (!isPlayerCaster(caster) && target != null) {
// NPC: charge toward aggro target
double dx = target.posX - caster.posX;
double dz = target.posZ - caster.posZ;
double len = Math.sqrt(dx * dx + dz * dz);
if (len > 0) {
chargeDirection = Vec3.createVectorHelper(dx / len, 0, dz / len);
} else {
float yaw = (float) Math.toRadians(caster.rotationYaw);
chargeDirection = Vec3.createVectorHelper(-Math.sin(yaw), 0, Math.cos(yaw));
}
} else {
// Player: charge in look direction
float yaw = (float) Math.toRadians(caster.rotationYaw);
chargeDirection = Vec3.createVectorHelper(-Math.sin(yaw), 0, Math.cos(yaw));
}
lockedYaw = (float) Math.toDegrees(Math.atan2(-chargeDirection.xCoord, chargeDirection.zCoord));
}
@Override
public void onExecute(EntityLivingBase caster, EntityLivingBase target) {
// Initialize charge - direction was already locked during windup
startX = caster.posX;
startY = caster.posY;
startZ = caster.posZ;
prevTickX = caster.posX;
prevTickZ = caster.posZ;
initMovement(caster, maxRange, chargeSpeed);
hitEntities.clear();
// Safety timeout: expected ticks + buffer to prevent infinite charge
maxActiveTicks = chargeSpeed > 0 ? (int)(maxRange / chargeSpeed) + 10 : 10;
// If direction wasn't set during windup (shouldn't happen), set it now
if (chargeDirection == null) {
lockChargeDirection(caster, target);
if (movementDirection == null) {
lockDirection(caster, target);
}
enforceLockedRotation(caster);
}
private void enforceLockedRotation(EntityLivingBase caster) {
caster.rotationYaw = lockedYaw;
caster.rotationYawHead = lockedYaw;
caster.prevRotationYaw = lockedYaw;
caster.prevRotationYawHead = lockedYaw;
caster.renderYawOffset = lockedYaw;
caster.prevRenderYawOffset = lockedYaw;
}
@Override
public void onActiveTick(EntityLivingBase caster, EntityLivingBase target, int tick) {
// Safety timeout or missing state: force-complete to prevent stuck NPC
if (!isPreview() && (chargeDirection == null || tick > maxActiveTicks)) {
if (checkTimeout(tick)) {
stopMomentum(caster);
signalCompletion();
return;
}
if (chargeDirection == null) {
if (movementDirection == null) {
signalCompletion();
return;
}
// Enforce rotation every tick
if (!isPreview()) {
enforceLockedRotation(caster);
}
// Stall detection: if entity hasn't moved since last tick, it's stuck against a wall
if (!isPreview() && tick > 1) {
double dx = caster.posX - prevTickX;
double dz = caster.posZ - prevTickZ;
if (dx * dx + dz * dz < 0.0001) {
stopMomentum(caster);
signalCompletion();
return;
}
if (checkStall(caster, tick)) {
stopMomentum(caster);
signalCompletion();
return;
}
prevTickX = caster.posX;
prevTickZ = caster.posZ;
updatePrevPosition(caster);
// Calculate distance traveled
double distanceTraveled = Math.sqrt(
Math.pow(caster.posX - startX, 2) +
Math.pow(caster.posZ - startZ, 2)
);
// Check if reached max distance
if (distanceTraveled >= maxRange) {
if (getDistanceTraveled(caster) >= maxRange) {
stopMomentum(caster);
signalCompletion();
return;
}
// Block detection (skip in preview - no real world collision)
if (!isPreview() && isChargeBlocked(caster)) {
if (checkBlocked(caster, chargeSpeed)) {
stopMomentum(caster);
signalCompletion();
return;
}
// Move caster
caster.motionX = chargeDirection.xCoord * chargeSpeed;
caster.motionY = 0;
caster.motionZ = chargeDirection.zCoord * chargeSpeed;
if (!isPreview()) {
caster.velocityChanged = true;
}
// Move caster (flat: zero vertical motion)
applyVelocityFlat(caster, chargeSpeed);
// Server-side collision damage (skip in preview)
if (!caster.worldObj.isRemote && !isPreview()) {
@@ -218,14 +141,11 @@ public class AbilityCharge extends Ability implements IAbilityCharge {
EntityLivingBase livingEntity = (EntityLivingBase) entity;
// Hit this entity
hitEntities.add(entity.getEntityId());
// Apply damage with scripted event support
boolean wasHit = applyAbilityDamageWithDirection(caster, livingEntity, damage, knockback,
chargeDirection.xCoord, chargeDirection.zCoord);
movementDirection.xCoord, movementDirection.zCoord);
// Apply effects and play impact sound if hit wasn't cancelled
if (wasHit) {
applyEffects(livingEntity);
caster.worldObj.playSoundAtEntity(livingEntity, "random.explode", 0.5f, 1.2f);
@@ -246,24 +166,10 @@ public class AbilityCharge extends Ability implements IAbilityCharge {
super.onInterrupt(caster, source, damage);
}
private void stopMomentum(EntityLivingBase caster) {
caster.motionX = 0;
caster.motionZ = 0;
if (!isPreview()) {
caster.velocityChanged = true;
}
}
private boolean isChargeBlocked(EntityLivingBase caster) {
return isMovementBlocked(caster, chargeDirection.xCoord, chargeDirection.zCoord, chargeSpeed);
}
@Override
public void reset() {
super.reset();
chargeDirection = null;
public void cleanup() {
super.cleanup();
hitEntities.clear();
maxActiveTicks = 0;
}
@Override
@@ -2,16 +2,12 @@ package kamkeel.npcs.controllers.data.ability.type;
import cpw.mods.fml.relauncher.Side;
import cpw.mods.fml.relauncher.SideOnly;
import kamkeel.npcs.controllers.data.ability.Ability;
import kamkeel.npcs.controllers.data.ability.LockMovementType;
import kamkeel.npcs.controllers.data.ability.TargetingMode;
import kamkeel.npcs.controllers.data.telegraph.TelegraphType;
import net.minecraft.entity.EntityLivingBase;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.util.AxisAlignedBB;
import net.minecraft.util.DamageSource;
import net.minecraft.util.Vec3;
import noppes.npcs.entity.EntityNPCInterface;
import noppes.npcs.client.gui.builder.FieldDef;
import noppes.npcs.api.ability.type.IAbilityDash;
@@ -22,9 +18,9 @@ import java.util.Random;
/**
* Dash ability: Quick evasive sidestep with NO telegraph.
* Defensive repositioning move to evade attacks.
* Extends AbilityMovement for shared direction locking, stall detection, and velocity application.
*/
public class AbilityDash extends Ability implements IAbilityDash {
public class AbilityDash extends AbilityMovement implements IAbilityDash {
/**
* Dash behavior mode.
@@ -88,12 +84,8 @@ public class AbilityDash extends Ability implements IAbilityDash {
private float dashDistance = 4.0f;
private float dashSpeed = 0.5f;
// Runtime state
private transient Vec3 dashDirection;
private transient double startX, startY, startZ;
private transient double prevTickX, prevTickZ;
// Type-specific runtime state
private transient DashDirection chosenDirection;
private transient int maxActiveTicks;
public AbilityDash() {
this.typeId = "ability.cnpc.dash";
@@ -126,21 +118,9 @@ public class AbilityDash extends Ability implements IAbilityDash {
return new TargetingMode[]{TargetingMode.AGGRO_TARGET};
}
@Override
public boolean hasAbilityMovement() {
return true; // This ability moves the NPC
}
@Override
public void onExecute(EntityLivingBase caster, EntityLivingBase target) {
startX = caster.posX;
startY = caster.posY;
startZ = caster.posZ;
prevTickX = caster.posX;
prevTickZ = caster.posZ;
// Safety timeout: expected ticks + generous buffer to prevent infinite dash
maxActiveTicks = dashSpeed > 0 ? (int)(dashDistance / dashSpeed) + 10 : 10;
initMovement(caster, dashDistance, dashSpeed);
// Choose random direction based on mode
DashDirection[] directions = dashMode == DashMode.AGGRESSIVE
@@ -148,26 +128,10 @@ public class AbilityDash extends Ability implements IAbilityDash {
: DEFENSIVE_DIRECTIONS;
chosenDirection = directions[RANDOM.nextInt(directions.length)];
// NPC: dash direction is relative to aggro target facing
// Player: dash direction is relative to look direction
float baseYaw;
if (!isPlayerCaster(caster) && target != null) {
double dx = target.posX - caster.posX;
double dz = target.posZ - caster.posZ;
baseYaw = (float) Math.toDegrees(Math.atan2(-dx, dz));
} else {
baseYaw = caster.rotationYaw;
}
// Apply direction offset
// Calculate direction: base yaw (toward target or look dir) + direction offset
float baseYaw = getBaseYaw(caster, target);
float dashYaw = baseYaw + chosenDirection.getAngleOffset();
float yawRad = (float) Math.toRadians(dashYaw);
dashDirection = Vec3.createVectorHelper(
-Math.sin(yawRad),
0,
Math.cos(yawRad)
);
setDirectionFromYaw(dashYaw);
// Small upward impulse for a skip/hop arc (gravity handles the descent)
caster.motionY = 0.2;
@@ -175,86 +139,59 @@ public class AbilityDash extends Ability implements IAbilityDash {
@Override
public void onActiveTick(EntityLivingBase caster, EntityLivingBase target, int tick) {
// Safety timeout or missing state: force-complete to prevent stuck NPC
if (!isPreview() && (dashDirection == null || tick > maxActiveTicks)) {
stopDash(caster);
if (checkTimeout(tick)) {
stopMomentum(caster);
signalCompletion();
return;
}
if (dashDirection == null) {
if (movementDirection == null) {
signalCompletion();
return;
}
// Stall detection: if entity hasn't moved since last tick, it's stuck against a wall
if (!isPreview() && tick > 1) {
double dx = caster.posX - prevTickX;
double dz = caster.posZ - prevTickZ;
if (dx * dx + dz * dz < 0.0001) {
stopDash(caster);
signalCompletion();
return;
}
if (checkStall(caster, tick)) {
stopMomentum(caster);
signalCompletion();
return;
}
prevTickX = caster.posX;
prevTickZ = caster.posZ;
updatePrevPosition(caster);
// Check if reached max distance
double travelDx = caster.posX - startX;
double travelDz = caster.posZ - startZ;
if (travelDx * travelDx + travelDz * travelDz >= (double) dashDistance * dashDistance) {
stopDash(caster);
if (getDistanceTraveledSq(caster) >= (double) dashDistance * dashDistance) {
stopMomentum(caster);
signalCompletion();
return;
}
// Block detection (skip in preview - no real world collision)
if (!isPreview() && isDashBlocked(caster)) {
stopDash(caster);
if (checkBlocked(caster, dashSpeed)) {
stopMomentum(caster);
signalCompletion();
return;
}
// Move caster (motionY left to gravity for skip arc)
caster.motionX = dashDirection.xCoord * dashSpeed;
caster.motionZ = dashDirection.zCoord * dashSpeed;
if (!isPreview()) {
caster.velocityChanged = true;
applyVelocity(caster, dashSpeed);
if (!isPreview()) {
// Trail particles
caster.worldObj.spawnParticle("smoke", caster.posX, caster.posY + 0.5, caster.posZ, 0, 0, 0);
}
}
private void stopDash(EntityLivingBase caster) {
caster.motionX = 0;
caster.motionZ = 0;
if (!isPreview()) {
caster.velocityChanged = true;
}
}
@Override
public void onComplete(EntityLivingBase caster, EntityLivingBase target) {
stopDash(caster);
stopMomentum(caster);
}
@Override
public void onInterrupt(EntityLivingBase caster, DamageSource source, float damage) {
stopDash(caster);
stopMomentum(caster);
}
@Override
public void cleanup() {
dashDirection = null;
super.cleanup();
chosenDirection = null;
maxActiveTicks = 0;
}
private boolean isDashBlocked(EntityLivingBase caster) {
if (dashDirection == null) return true;
return isMovementBlocked(caster, dashDirection.xCoord, dashDirection.zCoord, dashSpeed);
}
@Override
@@ -0,0 +1,270 @@
package kamkeel.npcs.controllers.data.ability.type;
import kamkeel.npcs.controllers.data.ability.Ability;
import net.minecraft.entity.EntityLivingBase;
import net.minecraft.util.Vec3;
/**
* Abstract base class for abilities that move the caster along the ground
* in a specific direction (Charge, Dash, etc.).
* <p>
* Provides shared movement logic: direction locking, stall detection with
* player-aware grace period, velocity application, distance tracking,
* rotation enforcement, and wall collision checking.
* <p>
* NOT for ballistic/arc movement (Slam uses a different pattern).
*/
public abstract class AbilityMovement extends Ability {
/**
* Grace period (in active ticks) before stall detection activates.
* Players need more time because:
* - Server sets motionX/Z and sends S12 velocity packet to client
* - Client receives S12 and starts moving
* - Client sends C03 position packet with updated position back to server
* Until the round trip completes, the server sees the old (stationary) position,
* which would falsely trigger stall detection.
*/
private static final int PLAYER_STALL_GRACE_TICKS = 5;
private static final int NPC_STALL_GRACE_TICKS = 2;
// ═══════════════════════════════════════════════════════════════════
// COMMON MOVEMENT STATE (transient, not saved to NBT)
// ═══════════════════════════════════════════════════════════════════
protected transient double startX, startY, startZ;
protected transient double prevTickX, prevTickZ;
protected transient Vec3 movementDirection;
protected transient float lockedYaw;
protected transient int maxActiveTicks;
// ═══════════════════════════════════════════════════════════════════
// OVERRIDES
// ═══════════════════════════════════════════════════════════════════
@Override
public boolean hasAbilityMovement() {
return true;
}
@Override
public void cleanup() {
movementDirection = null;
maxActiveTicks = 0;
}
// ═══════════════════════════════════════════════════════════════════
// DIRECTION HELPERS
// ═══════════════════════════════════════════════════════════════════
/**
* Lock direction based on caster type.
* NPC: toward target. Player: look direction.
*/
protected void lockDirection(EntityLivingBase caster, EntityLivingBase target) {
if (!isPlayerCaster(caster) && target != null) {
lockDirectionToTarget(caster, target);
} else {
lockDirectionFromLook(caster);
}
}
/**
* Lock direction to the caster's look direction (horizontal only).
*/
protected void lockDirectionFromLook(EntityLivingBase caster) {
float yaw = (float) Math.toRadians(caster.rotationYaw);
movementDirection = Vec3.createVectorHelper(-Math.sin(yaw), 0, Math.cos(yaw));
lockedYaw = computeYawFromDirection();
}
/**
* Lock direction toward a target entity.
* Falls back to look direction if target is at the same position.
*/
protected void lockDirectionToTarget(EntityLivingBase caster, EntityLivingBase target) {
double dx = target.posX - caster.posX;
double dz = target.posZ - caster.posZ;
double len = Math.sqrt(dx * dx + dz * dz);
if (len > 0) {
movementDirection = Vec3.createVectorHelper(dx / len, 0, dz / len);
lockedYaw = computeYawFromDirection();
} else {
lockDirectionFromLook(caster);
}
}
/**
* Get the base yaw for direction calculation.
* NPC: yaw toward target. Player: current look yaw.
*/
protected float getBaseYaw(EntityLivingBase caster, EntityLivingBase target) {
if (!isPlayerCaster(caster) && target != null) {
double dx = target.posX - caster.posX;
double dz = target.posZ - caster.posZ;
return (float) Math.toDegrees(Math.atan2(-dx, dz));
}
return caster.rotationYaw;
}
/**
* Set movement direction from a yaw angle (degrees).
*/
protected void setDirectionFromYaw(float yawDegrees) {
float yawRad = (float) Math.toRadians(yawDegrees);
movementDirection = Vec3.createVectorHelper(-Math.sin(yawRad), 0, Math.cos(yawRad));
lockedYaw = yawDegrees;
}
// ═══════════════════════════════════════════════════════════════════
// MOVEMENT INITIALIZATION
// ═══════════════════════════════════════════════════════════════════
/**
* Initialize movement tracking. Call from onExecute().
*
* @param caster The entity performing the movement
* @param distance Maximum travel distance (for timeout computation)
* @param speed Movement speed in blocks/tick (for timeout computation)
*/
protected void initMovement(EntityLivingBase caster, float distance, float speed) {
startX = caster.posX;
startY = caster.posY;
startZ = caster.posZ;
prevTickX = caster.posX;
prevTickZ = caster.posZ;
maxActiveTicks = speed > 0 ? (int) (distance / speed) + 10 : 10;
}
// ═══════════════════════════════════════════════════════════════════
// ACTIVE TICK CHECKS
// ═══════════════════════════════════════════════════════════════════
/**
* Check for timeout or missing movement direction.
*
* @return true if the ability should stop
*/
protected boolean checkTimeout(int tick) {
return !isPreview() && (movementDirection == null || tick > maxActiveTicks);
}
/**
* Check stall detection with appropriate grace period for player vs NPC.
* Players need a longer grace period because velocity sync via S12 packet
* takes time to reach the client, and C03 position packets lag behind.
*
* @return true if the entity is stalled (not moving)
*/
protected boolean checkStall(EntityLivingBase caster, int tick) {
if (isPreview()) return false;
int graceTicks = isPlayerCaster(caster) ? PLAYER_STALL_GRACE_TICKS : NPC_STALL_GRACE_TICKS;
if (tick <= graceTicks) return false;
double dx = caster.posX - prevTickX;
double dz = caster.posZ - prevTickZ;
return dx * dx + dz * dz < 0.0001;
}
/**
* Update previous tick position for stall detection.
* Call after stall check, before movement application.
*/
protected void updatePrevPosition(EntityLivingBase caster) {
prevTickX = caster.posX;
prevTickZ = caster.posZ;
}
/**
* Get total linear distance traveled from start position.
*/
protected double getDistanceTraveled(EntityLivingBase caster) {
double dx = caster.posX - startX;
double dz = caster.posZ - startZ;
return Math.sqrt(dx * dx + dz * dz);
}
/**
* Get total distance traveled squared (more efficient for comparison).
*/
protected double getDistanceTraveledSq(EntityLivingBase caster) {
double dx = caster.posX - startX;
double dz = caster.posZ - startZ;
return dx * dx + dz * dz;
}
/**
* Check if movement direction is blocked by walls.
*
* @return true if blocked
*/
protected boolean checkBlocked(EntityLivingBase caster, float speed) {
if (isPreview() || movementDirection == null) return false;
return isMovementBlocked(caster, movementDirection.xCoord, movementDirection.zCoord, speed);
}
// ═══════════════════════════════════════════════════════════════════
// VELOCITY APPLICATION
// ═══════════════════════════════════════════════════════════════════
/**
* Apply movement velocity to the caster (horizontal only, Y unchanged).
*/
protected void applyVelocity(EntityLivingBase caster, float speed) {
caster.motionX = movementDirection.xCoord * speed;
caster.motionZ = movementDirection.zCoord * speed;
if (!isPreview()) {
caster.velocityChanged = true;
}
}
/**
* Apply movement velocity with zero vertical motion.
* Used for ground-locked movement like Charge.
*/
protected void applyVelocityFlat(EntityLivingBase caster, float speed) {
caster.motionX = movementDirection.xCoord * speed;
caster.motionY = 0;
caster.motionZ = movementDirection.zCoord * speed;
if (!isPreview()) {
caster.velocityChanged = true;
}
}
/**
* Stop all horizontal momentum.
*/
protected void stopMomentum(EntityLivingBase caster) {
caster.motionX = 0;
caster.motionZ = 0;
if (!isPreview()) {
caster.velocityChanged = true;
}
}
// ═══════════════════════════════════════════════════════════════════
// ROTATION
// ═══════════════════════════════════════════════════════════════════
/**
* Compute yaw angle (degrees) from current movement direction.
*/
protected float computeYawFromDirection() {
if (movementDirection == null) return 0;
return (float) Math.toDegrees(Math.atan2(-movementDirection.xCoord, movementDirection.zCoord));
}
/**
* Enforce locked rotation on all yaw fields.
* Sets rotationYaw, rotationYawHead, prevRotationYaw, prevRotationYawHead,
* renderYawOffset, and prevRenderYawOffset to lockedYaw.
*/
protected void enforceLockedRotation(EntityLivingBase caster) {
caster.rotationYaw = lockedYaw;
caster.rotationYawHead = lockedYaw;
caster.prevRotationYaw = lockedYaw;
caster.prevRotationYawHead = lockedYaw;
caster.renderYawOffset = lockedYaw;
caster.prevRenderYawOffset = lockedYaw;
}
}