Files
CustomNPC-Plus/src/main/java/noppes/npcs/DataAbilities.java
T

1189 lines
46 KiB
Java
Raw Normal View History

2026-01-09 15:29:00 -05:00
package noppes.npcs;
2026-02-18 04:02:51 -05:00
import kamkeel.npcs.controllers.AbilityController;
2026-02-18 05:13:51 -05:00
import kamkeel.npcs.controllers.data.ability.Ability;
import kamkeel.npcs.controllers.data.ability.AbilityAction;
2026-02-22 03:28:56 -05:00
import kamkeel.npcs.controllers.data.ability.enums.AbilityPhase;
2026-03-02 20:09:32 -05:00
import kamkeel.npcs.controllers.data.ability.enums.RotationMode;
2026-02-22 03:28:56 -05:00
import kamkeel.npcs.controllers.data.ability.data.ChainedAbility;
import kamkeel.npcs.controllers.data.ability.data.IAbilityAction;
import kamkeel.npcs.controllers.data.ability.data.entry.AbilityToggleEntry;
2026-02-28 23:23:52 -05:00
import kamkeel.npcs.controllers.data.ability.type.AbilityDefend;
2026-01-24 02:35:26 -05:00
import kamkeel.npcs.controllers.data.telegraph.TelegraphInstance;
import kamkeel.npcs.network.packets.data.telegraph.TelegraphRemovePacket;
import kamkeel.npcs.network.packets.data.telegraph.TelegraphSpawnPacket;
2026-02-28 23:23:52 -05:00
import net.minecraft.entity.Entity;
2026-01-09 15:29:00 -05:00
import net.minecraft.entity.EntityLivingBase;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.nbt.NBTTagList;
import net.minecraft.util.DamageSource;
import noppes.npcs.controllers.data.Animation;
import noppes.npcs.entity.EntityNPCInterface;
import noppes.npcs.scripted.event.AbilityEvent;
import noppes.npcs.EventHooks;
2026-01-09 15:29:00 -05:00
import java.util.ArrayList;
import java.util.List;
2026-02-18 22:19:18 -05:00
import java.util.Map;
2026-01-09 15:29:00 -05:00
import java.util.Random;
/**
* Manages NPC abilities - storage, selection, execution, and cooldowns.
2026-02-17 21:03:54 -05:00
* Extends AbstractDataAbilities for shared ability lifecycle logic.
2026-01-09 15:29:00 -05:00
*/
2026-02-17 21:03:54 -05:00
public class DataAbilities extends AbstractDataAbilities {
2026-01-09 15:29:00 -05:00
private final EntityNPCInterface npc;
private final Random random = new Random();
// ═══════════════════════════════════════════════════════════════════
// CONFIGURATION (saved to NBT)
// ═══════════════════════════════════════════════════════════════════
/**
* Unified list of action slots (abilities and chained abilities).
*/
private List<AbilityAction> actionSlots = new ArrayList<>();
2026-01-09 15:29:00 -05:00
/**
* Whether the ability system is enabled for this NPC
*/
public boolean enabled = true;
2026-01-09 15:29:00 -05:00
/**
2026-01-26 04:42:23 -05:00
* Minimum cooldown ticks between abilities (global minimum)
*/
2026-01-26 04:42:23 -05:00
public int minCooldown = 80;
/**
* Maximum cooldown ticks between abilities (global maximum)
*/
public int maxCooldown = 200;
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
2026-02-17 21:03:54 -05:00
// NPC-SPECIFIC RUNTIME STATE (not saved)
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
/**
* Last target used for ability execution
*/
2026-01-09 15:29:00 -05:00
private transient EntityLivingBase lastTarget;
/**
* Recent hit timestamps for hit count condition
*/
2026-01-09 15:29:00 -05:00
private transient List<Long> recentHitTimes = new ArrayList<>();
2026-01-26 19:03:48 -05:00
/**
2026-02-17 21:03:54 -05:00
* NPC-specific locked rotation fields (yawHead, renderYawOffset).
* Base class handles lockedYaw and lockedPitch.
2026-01-26 19:03:48 -05:00
*/
private transient float lockedYawHead = 0;
private transient float lockedRenderYawOffset = 0;
2026-02-06 16:28:38 -05:00
/**
* Hit scan state - forces NPC to face target.
2026-02-17 21:03:54 -05:00
* Set during onPostPhaseTick(), applied after super.onLivingUpdate()
2026-02-06 16:28:38 -05:00
* to override AI look helper rotation.
*/
private transient boolean hitScanActive = false;
private transient EntityLivingBase hitScanTarget = null;
private transient float currentTrackSpeed = 0;
2026-03-07 15:12:55 -05:00
private transient float trackedYaw = 0;
private transient float trackedPitch = 0;
private transient boolean trackLerpedThisTick = false;
private transient boolean preserveTrackedRotation = false;
2026-02-06 16:28:38 -05:00
2026-02-18 05:13:51 -05:00
/**
* Bit flag for rotation control (LOCKED or TRACK) in data watcher slot 15
*/
2026-02-17 21:03:54 -05:00
private static final int ROTATION_CONTROLLED_FLAG = 16;
2026-02-18 05:13:51 -05:00
/**
* Bit flag for position lock in data watcher slot 15
*/
2026-02-17 21:03:54 -05:00
private static final int POSITION_LOCKED_FLAG = 32;
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
// CONSTRUCTOR
// ═══════════════════════════════════════════════════════════════════
public DataAbilities(EntityNPCInterface npc) {
this.npc = npc;
}
2026-02-17 21:03:54 -05:00
// ═══════════════════════════════════════════════════════════════════
// ABSTRACT METHOD IMPLEMENTATIONS
// ═══════════════════════════════════════════════════════════════════
@Override
protected EntityLivingBase getEntity() {
return npc;
}
@Override
protected EntityLivingBase getTarget() {
return lastTarget != null ? lastTarget : npc.getAttackTarget();
}
@Override
protected long getWorldTime() {
return npc.worldObj.getTotalWorldTime();
}
@Override
protected void spawnTelegraph(Ability ability, EntityLivingBase target) {
List<TelegraphInstance> telegraphs = ability.createTelegraphs(npc, target);
if (!telegraphs.isEmpty()) {
ability.setTelegraphInstances(telegraphs);
for (TelegraphInstance telegraph : telegraphs) {
TelegraphSpawnPacket.sendToTracking(telegraph, npc);
}
}
}
@Override
protected void removeTelegraph(Ability ability) {
List<TelegraphInstance> telegraphs = ability.getTelegraphInstances();
for (TelegraphInstance telegraph : telegraphs) {
TelegraphRemovePacket.sendToTracking(telegraph.getInstanceId(), npc);
}
ability.setTelegraphInstances(null);
}
@Override
protected void setAnimationData(Animation animation) {
npc.display.animationData.setEnabled(true);
npc.display.animationData.setAnimation(animation);
npc.display.animationData.updateClient();
}
@Override
protected void clearAnimationData() {
npc.display.animationData.setAnimation(null);
npc.display.animationData.updateClient();
}
@Override
protected void playAbilitySound(String sound) {
if (sound != null && !sound.isEmpty()) {
npc.worldObj.playSoundAtEntity(npc, sound, 1.0f, 1.0f);
}
}
@Override
protected void captureLockedRotation() {
lockedYaw = npc.rotationYaw;
lockedYawHead = npc.rotationYawHead;
lockedRenderYawOffset = npc.renderYawOffset;
lockedPitch = npc.rotationPitch;
rotationLocked = true;
npc.setBoolFlag(true, ROTATION_CONTROLLED_FLAG);
}
@Override
protected void rollCooldown(Ability ability) {
// Always roll NPC global cooldown (pacing timer)
2026-02-17 21:03:54 -05:00
int baseCooldown = minCooldown;
if (maxCooldown > minCooldown) {
baseCooldown = minCooldown + random.nextInt(maxCooldown - minCooldown + 1);
}
2026-08-11 03:24:59 -04:00
long now = npc.worldObj.getTotalWorldTime();
if (ability.isPerAbilityCooldown() && ability.getCooldownTicks() > 0) {
2026-08-11 03:24:59 -04:00
// The ability's own cooldown is tracked against the ability, so the global timer
// only paces how often the NPC acts. Folding the ability's cooldown into it as
// well would always outlast the per-ability entry and make it unreachable.
cooldownEndTime = now + baseCooldown;
setPerAbilityCooldown(ability.getName(), now + ability.getCooldownTicks(), ability.getCooldownTicks());
} else {
cooldownEndTime = now + baseCooldown + ability.getCooldownTicks();
}
2026-02-17 21:03:54 -05:00
}
@Override
protected void rollChainCooldown(ChainedAbility chain) {
2026-08-11 03:24:59 -04:00
long now = npc.worldObj.getTotalWorldTime();
boolean perAbility = chain.isPerAbilityCooldown() && chain.getCooldownTicks() > 0;
2026-03-13 18:54:18 -04:00
if (!chain.isIgnoreCooldown()) {
int baseCooldown = minCooldown;
if (maxCooldown > minCooldown) {
baseCooldown = minCooldown + random.nextInt(maxCooldown - minCooldown + 1);
}
2026-08-11 03:24:59 -04:00
cooldownEndTime = perAbility ? now + baseCooldown : now + baseCooldown + chain.getCooldownTicks();
2026-03-13 18:54:18 -04:00
}
2026-08-11 03:24:59 -04:00
if (perAbility) {
setPerAbilityCooldown(chain.getName(), now + chain.getCooldownTicks(), chain.getCooldownTicks());
}
}
@Override
protected EntityLivingBase retargetForChain() {
return npc.getAttackTarget();
}
2026-02-17 21:03:54 -05:00
@Override
protected void onAbilityComplete() {
currentAbility = null;
lastTarget = null;
}
// ═══════════════════════════════════════════════════════════════════
// HOOK OVERRIDES
// ═══════════════════════════════════════════════════════════════════
@Override
protected void onPreExecute(Ability ability, EntityLivingBase target) {
// Face target before execute so startMoving() reads correct rotation
// Use trackSpeed so the NPC plays catchup rather than instant-snapping
2026-02-17 21:03:54 -05:00
if (ability.isHitScanForCurrentPhase() && target != null) {
float speed = ability.getTrackSpeed();
if (speed <= 0) {
faceTarget(target);
} else {
faceTarget(target, speed);
}
2026-02-17 21:03:54 -05:00
}
}
@Override
protected void onPostPhaseTick(Ability ability, EntityLivingBase target) {
2026-03-07 15:12:55 -05:00
// Reset per-tick lerp flag so faceTarget only computes the lerp once per tick
trackLerpedThisTick = false;
2026-02-17 21:03:54 -05:00
// Update hit scan state - actual facing is deferred to applyRotationControl()
// which runs AFTER super.onLivingUpdate() to override AI look helper
2026-03-02 20:09:32 -05:00
if (ability != null && ability.isExecuting() && target != null) {
if (ability.isHitScanForCurrentPhase()) {
enableHitScan(target, ability.getTrackSpeed());
2026-03-02 20:09:32 -05:00
} else {
// Release hit scan if it was previously active
if (hitScanActive) {
releaseRotationControl();
}
// FREE mode: use AI look helper during ability execution since combat AI may not run
if (ability.getRotationMode() == RotationMode.FREE) {
npc.getLookHelper().setLookPositionWithEntity(target, 30.0F, 30.0F);
}
}
2026-02-17 21:03:54 -05:00
} else if (hitScanActive) {
releaseRotationControl();
}
// Apply movement control if ability is still executing
if (ability != null && ability.isExecuting()) {
applyMovementControl();
}
}
@Override
protected void onBurstDelayReleaseLocks() {
if (hitScanActive) {
preserveTrackedRotation = true;
releaseRotationControl();
}
2026-02-17 21:03:54 -05:00
}
@Override
protected void onPositionLockChanged(boolean locked) {
npc.setBoolFlag(locked, POSITION_LOCKED_FLAG);
}
@Override
protected void onRotationLockChanged(boolean locked) {
if (!locked) {
hitScanActive = false;
hitScanTarget = null;
}
npc.setBoolFlag(locked, ROTATION_CONTROLLED_FLAG);
}
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
// TICK - Called from EntityNPCInterface.onLivingUpdate()
// ═══════════════════════════════════════════════════════════════════
/**
* Main tick method - handles ability execution and cooldown management.
* Should be called every tick from onLivingUpdate().
*/
public void tick() {
2026-02-06 03:34:45 -05:00
if (npc.worldObj.isRemote) {
2026-01-09 15:29:00 -05:00
return;
}
2026-02-06 03:34:45 -05:00
// Force-stop any running ability when NPC is killed or abilities are disabled
if (!enabled || npc.isKilled()) {
if (currentAbility != null) {
removeTelegraph(currentAbility);
stopAbilityAnimation();
2026-02-06 16:28:38 -05:00
releaseRotationControl();
2026-02-06 03:34:45 -05:00
releaseLockedPosition();
currentAbility = null;
lastTarget = null;
} else {
// No ability but locks could be orphaned
2026-02-06 16:28:38 -05:00
if (rotationLocked || hitScanActive) releaseRotationControl();
2026-02-06 03:34:45 -05:00
if (positionLocked) releaseLockedPosition();
}
// Clear chain and concurrent state
2026-03-03 22:15:49 -05:00
if (currentChain != null) currentChain.clearInstanceScript();
currentChain = null;
chainEntryIndex = -1;
chainDelayRemaining = -1;
interruptConcurrentSlots();
2026-02-06 03:34:45 -05:00
return;
}
// Safety: release orphaned locks if no ability is actively executing
if ((currentAbility == null || !currentAbility.isExecuting()) && chainDelayRemaining <= 0) {
2026-02-06 16:28:38 -05:00
if (rotationLocked || hitScanActive) releaseRotationControl();
2026-02-06 03:34:45 -05:00
if (positionLocked) releaseLockedPosition();
}
// Tick active toggles (independent of currentAbility)
tickActiveToggles();
// Tick current ability if executing, or tick chain delay between entries
if (chainDelayRemaining > 0 || (currentAbility != null && currentAbility.isExecuting())) {
2026-01-09 15:29:00 -05:00
tickCurrentAbility();
}
}
2026-01-28 15:16:28 -05:00
/**
* Apply movement control based on lock movement settings.
2026-02-17 21:03:54 -05:00
* Called every tick during ability execution via onPostPhaseTick().
2026-01-28 15:16:28 -05:00
*/
private void applyMovementControl() {
if (currentAbility == null) return;
// Check if movement should be locked for the current phase
if (currentAbility.isMovementLockedForCurrentPhase()) {
// Clear navigator to prevent AI pathfinding
npc.getNavigator().clearPathEntity();
// Zero motion if ability doesn't have its own movement
// This allows abilities like Charge/Slam to control their own motion
if (!currentAbility.hasAbilityMovement()) {
npc.motionX = 0;
npc.motionZ = 0;
}
}
2026-01-27 04:12:39 -05:00
}
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
// ABILITY SELECTION - Called from CombatHandler
// ═══════════════════════════════════════════════════════════════════
/**
* Try to select and start an ability or chained ability for the given target.
2026-01-09 15:29:00 -05:00
* Called by CombatHandler when NPC is in combat and ready for an ability.
*
* @param target The combat target
* @return true if an ability was started
*/
public boolean trySelectAndStart(EntityLivingBase target) {
if (!canSelectAbility()) {
return false;
}
// Build eligible pool from unified action slots
List<IAbilityAction> eligible = new ArrayList<>();
List<Integer> weights = new ArrayList<>();
int totalWeight = 0;
for (AbilityAction slot : actionSlots) {
IAbilityAction action = slot.getAction();
if (action != null && isActionEligible(slot, action, target)) {
eligible.add(action);
weights.add(action.getWeight());
totalWeight += action.getWeight();
}
}
if (eligible.isEmpty() || totalWeight <= 0) {
2026-01-09 15:29:00 -05:00
return false;
}
// Weighted random selection
int roll = random.nextInt(totalWeight);
int cumulative = 0;
IAbilityAction selected = null;
for (int i = 0; i < eligible.size(); i++) {
cumulative += weights.get(i);
if (roll < cumulative) {
selected = eligible.get(i);
break;
}
}
if (selected == null) {
selected = eligible.get(eligible.size() - 1);
}
// Dispatch
lastTarget = target;
if (selected.isChain()) {
return startChain((ChainedAbility) selected, target);
} else {
return startAbility((Ability) selected, target);
}
2026-01-09 15:29:00 -05:00
}
/**
* Check if an ability can be selected right now.
*/
public boolean canSelectAbility() {
if (!enabled || actionSlots.isEmpty()) {
2026-01-09 15:29:00 -05:00
return false;
}
if (currentAbility != null && currentAbility.isExecuting()) {
return false;
}
if (isExecutingChain()) {
return false;
}
2026-01-26 04:42:23 -05:00
// Check if still on cooldown
if (npc.worldObj.getTotalWorldTime() < cooldownEndTime) {
2026-01-09 15:29:00 -05:00
return false;
}
return true;
}
/**
* Check if an action (ability or chain) is eligible for use.
2026-01-09 15:29:00 -05:00
*/
private boolean isActionEligible(AbilityAction slot, IAbilityAction action, EntityLivingBase target) {
if (!action.getAllowedBy().allowsNpc()) {
return false;
2026-01-09 15:29:00 -05:00
}
if (!slot.isSlotEnabled()) {
return false;
}
// Ability-specific: check if already executing
if (!action.isChain() && ((Ability) action).isExecuting()) {
2026-01-09 15:29:00 -05:00
return false;
}
// Chain-specific: must have entries
if (action.isChain() && ((ChainedAbility) action).getEntries().isEmpty()) {
2026-01-09 15:29:00 -05:00
return false;
}
// Range check
2026-01-09 15:29:00 -05:00
if (target != null) {
float distance = npc.getDistanceToEntity(target);
if (distance < action.getMinRange() || distance > action.getMaxRange()) {
2026-01-09 15:29:00 -05:00
return false;
}
}
// Conditions
if (!action.checkConditions(npc, target)) {
2026-01-09 15:29:00 -05:00
return false;
}
2026-03-13 18:54:18 -04:00
// Per-ability cooldown filter: ability/chain may have its own independent cooldown
if (!action.isChain()) {
Ability ab = (Ability) action;
if (ab.isPerAbilityCooldown() && isOnPerAbilityCooldown(ab.getName())) {
return false;
}
2026-03-13 18:54:18 -04:00
} else {
ChainedAbility ch = (ChainedAbility) action;
if (ch.isPerAbilityCooldown() && isOnPerAbilityCooldown(ch.getName())) {
return false;
}
}
2026-01-09 15:29:00 -05:00
return true;
}
/**
* Start executing an ability.
*/
private boolean startAbility(Ability ability, EntityLivingBase target) {
// Fire start event (cancelable)
if (EventHooks.onAbilityStart(ability, npc, target)) {
2026-01-09 15:29:00 -05:00
return false;
}
// Fire extender start hook (e.g., resource cost checks)
if (!AbilityController.Instance.fireOnAbilityStart(ability, npc, target)) {
return false;
}
2026-01-09 15:29:00 -05:00
currentAbility = ability;
lastTarget = target;
ability.start(target);
2026-02-03 15:52:46 -05:00
if (ability.getPhase() == AbilityPhase.ACTIVE) {
2026-02-17 21:03:54 -05:00
// Windup was 0 — capture locks for immediate active phase, then execute
if (ability.isRotationLockedDuringActive()) {
captureLockedRotation();
}
if (ability.isMovementLockedDuringActive() && !ability.hasAbilityMovement()) {
captureLockedPosition();
}
2026-02-03 15:52:46 -05:00
executeImmediate(ability, target);
} else {
// Normal windup flow
if (ability.isRotationLockedDuringWindup()) {
captureLockedRotation();
}
if (ability.isMovementLockedDuringWindup() && !ability.hasAbilityMovement()) {
captureLockedPosition();
}
2026-02-03 15:52:46 -05:00
spawnTelegraph(ability, target);
playAbilitySound(ability.getWindUpSound());
playAbilityAnimation(ability.getWindUpAnimation());
}
2026-01-09 15:29:00 -05:00
2026-02-03 15:52:46 -05:00
return true;
}
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
// INTERRUPTION - Called from CombatHandler on damage
// ═══════════════════════════════════════════════════════════════════
/**
2026-01-25 20:39:01 -05:00
* Handle damage taken - may interrupt current ability or trigger guard counter.
2026-01-09 15:29:00 -05:00
*
* @param source The damage source
* @param amount The damage amount
* @return true if ability was interrupted
*/
public boolean onDamage(DamageSource source, float amount) {
if (currentAbility == null || !currentAbility.isExecuting()) {
return false;
}
if (currentAbility.isInvulnerableForCurrentPhase()) {
return false;
}
// Track hit for hit count condition
recordHit();
if (currentAbility.canInterrupt(source)) {
2026-01-09 15:29:00 -05:00
interruptCurrentAbility(source, amount);
return true;
}
return false;
}
/**
* Record a hit for the hit count condition.
2026-01-26 04:42:23 -05:00
* Includes periodic cleanup to prevent memory leak.
2026-01-09 15:29:00 -05:00
*/
private void recordHit() {
2026-01-26 04:42:23 -05:00
long currentTime = npc.worldObj.getTotalWorldTime();
recentHitTimes.add(currentTime);
// Cleanup old entries periodically to prevent unbounded growth
// Keep only hits from last 5 minutes (6000 ticks) max
if (recentHitTimes.size() > 50) {
long cutoff = currentTime - 6000;
recentHitTimes.removeIf(time -> time < cutoff);
}
2026-01-09 15:29:00 -05:00
}
/**
* Get the number of hits received within the specified tick window.
*
* @param withinTicks The time window in ticks
* @return Number of hits in that window
*/
public int getRecentHitCount(int withinTicks) {
long currentTime = npc.worldObj.getTotalWorldTime();
long cutoff = currentTime - withinTicks;
// Clean up old entries and count
int count = 0;
recentHitTimes.removeIf(time -> time < cutoff);
for (Long time : recentHitTimes) {
if (time >= cutoff) {
count++;
}
}
return count;
}
/**
* Force stop the current ability (no interrupt event).
*/
public void stopCurrentAbility() {
if (currentAbility != null) {
removeTelegraph(currentAbility);
stopAbilityAnimation();
2026-02-06 16:28:38 -05:00
releaseRotationControl();
releaseLockedPosition();
2026-01-09 15:29:00 -05:00
currentAbility.interrupt();
currentAbility = null;
lastTarget = null;
}
// Also clear chain state
2026-03-03 22:15:49 -05:00
if (currentChain != null) currentChain.clearInstanceScript();
currentChain = null;
chainEntryIndex = -1;
chainDelayRemaining = -1;
2026-01-09 15:29:00 -05:00
}
2026-01-28 14:44:19 -05:00
/**
* Force start a specific ability, bypassing normal selection and cooldown.
* If an ability is currently executing, it will be cancelled.
*
* @param ability The ability to start
2026-02-18 05:13:51 -05:00
* @param target The target entity (can be null for self-targeted abilities)
2026-01-28 14:44:19 -05:00
* @return true if the ability was started successfully
*/
public boolean forceStartAbility(Ability ability, EntityLivingBase target) {
if (ability == null || npc.worldObj.isRemote) {
return false;
}
// Check UserType allows NPCs
if (!ability.getAllowedBy().allowsNpc()) {
return false;
}
2026-01-28 14:44:19 -05:00
// Stop any currently executing ability
stopCurrentAbility();
// Reset the ability state
ability.reset();
// Start the ability directly
return startAbility(ability, target);
}
/**
2026-01-30 05:01:54 -05:00
* Execute an ability on this NPC by key (built-in name or custom UUID).
2026-01-28 14:44:19 -05:00
* The NPC does NOT need to have this ability assigned.
*
2026-02-18 05:13:51 -05:00
* @param key The ability key (built-in name or custom UUID)
2026-01-28 14:44:19 -05:00
* @param target The target entity (can be null for self-targeted abilities)
* @return true if the ability was started successfully
*/
2026-01-30 05:01:54 -05:00
public boolean executeAbility(String key, EntityLivingBase target) {
if (key == null || key.isEmpty() || npc.worldObj.isRemote) {
2026-01-28 14:44:19 -05:00
return false;
}
2026-01-30 05:01:54 -05:00
Ability resolved = AbilityController.Instance.resolveAbility(key);
if (resolved == null) {
2026-01-28 14:44:19 -05:00
return false;
}
2026-01-30 05:01:54 -05:00
return forceStartAbility(resolved, target);
2026-01-28 14:44:19 -05:00
}
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
// SCRIPT EVENTS
// ═══════════════════════════════════════════════════════════════════
/**
* Get the NPC this DataAbilities belongs to.
*/
public EntityNPCInterface getNpc() {
return npc;
}
// ═══════════════════════════════════════════════════════════════════
// RESET - Called on NPC death/respawn
// ═══════════════════════════════════════════════════════════════════
/**
* Reset all runtime state (cooldowns, current ability).
* Called when NPC respawns or combat ends.
* Rolls cooldown so NPC doesn't immediately use an ability.
2026-01-09 15:29:00 -05:00
*/
public void reset() {
stopCurrentAbility();
clearActiveToggles();
interruptConcurrentSlots();
resetAllPerAbilityCooldowns();
// Roll cooldown so NPC doesn't immediately attack after reset
2026-02-17 21:03:54 -05:00
rollCooldownOnReset();
2026-01-09 15:29:00 -05:00
2026-01-30 05:01:54 -05:00
// Reset execution state on all resolved abilities
for (Ability ability : getAbilities()) {
2026-01-09 15:29:00 -05:00
ability.reset();
}
}
/**
2026-02-17 21:03:54 -05:00
* Roll a new cooldown using the min/max range (without ability offset).
* Called on reset when no ability is involved.
*/
2026-02-17 21:03:54 -05:00
private void rollCooldownOnReset() {
int baseCooldown = minCooldown;
if (maxCooldown > minCooldown) {
baseCooldown = minCooldown + random.nextInt(maxCooldown - minCooldown + 1);
}
cooldownEndTime = npc.worldObj.getTotalWorldTime() + baseCooldown;
}
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
// ACTION SLOT MANAGEMENT
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
2026-02-18 05:13:51 -05:00
/**
* Get the unified action slot list.
*/
public List<AbilityAction> getAbilityActions() {
return actionSlots;
}
2026-01-30 05:01:54 -05:00
/**
* Get resolved abilities from all ability slots, filtering out broken references and chains.
2026-01-30 05:01:54 -05:00
*/
2026-01-09 15:29:00 -05:00
public List<Ability> getAbilities() {
2026-01-30 05:01:54 -05:00
List<Ability> resolved = new ArrayList<>();
for (AbilityAction slot : actionSlots) {
2026-01-30 05:01:54 -05:00
Ability a = slot.getAbility();
if (a != null) {
resolved.add(a);
}
}
return resolved;
2026-01-09 15:29:00 -05:00
}
2026-02-18 05:13:51 -05:00
/**
* Add an inline ability.
*/
2026-01-09 15:29:00 -05:00
public void addAbility(Ability ability) {
actionSlots.add(AbilityAction.inline(ability));
2026-01-30 05:01:54 -05:00
}
2026-02-18 05:13:51 -05:00
/**
* Add a reference ability by key (built-in name or custom ability name).
*/
2026-01-30 05:01:54 -05:00
public void addAbilityReference(String key) {
actionSlots.add(AbilityAction.abilityReference(key));
2026-01-09 15:29:00 -05:00
}
2026-02-18 05:13:51 -05:00
/**
* Add a chained ability reference by name.
*/
public void addChainReference(String name) {
if (name != null && !name.isEmpty()) {
for (AbilityAction slot : actionSlots) {
if (slot.isChainReference() && name.equals(slot.getReferenceId())) {
return; // Prevent duplicates
}
}
actionSlots.add(AbilityAction.chainReference(name));
}
}
2026-02-18 05:13:51 -05:00
/**
* Remove an action slot by index.
*/
public void removeAction(int index) {
if (index >= 0 && index < actionSlots.size()) {
actionSlots.remove(index);
2026-01-09 15:29:00 -05:00
}
}
public void removeAbility(int index) {
removeAction(index);
}
2026-01-09 15:29:00 -05:00
public void removeAbility(String id) {
actionSlots.removeIf(slot -> {
if (slot.isAbilityReference()) {
2026-01-30 05:01:54 -05:00
return slot.getReferenceId().equals(id);
}
if (slot.isChainReference()) {
return false; // Don't remove chain references via removeAbility
}
2026-01-30 05:01:54 -05:00
Ability a = slot.getAbility();
return a != null && a.getId().equals(id);
});
2026-01-09 15:29:00 -05:00
}
public Ability getAbility(String id) {
for (AbilityAction slot : actionSlots) {
2026-01-30 05:01:54 -05:00
Ability a = slot.getAbility();
if (a != null && a.getId().equals(id)) {
return a;
2026-01-09 15:29:00 -05:00
}
}
return null;
}
2026-02-18 05:13:51 -05:00
/**
* Check if a slot at a given index is a reference.
*/
2026-01-30 05:01:54 -05:00
public boolean isSlotReference(int index) {
if (index < 0 || index >= actionSlots.size()) return false;
return actionSlots.get(index).isReference();
2026-01-30 05:01:54 -05:00
}
2026-02-18 05:13:51 -05:00
/**
* Convert a reference slot to inline. Returns false if resolution fails or is a chain.
*/
2026-01-30 05:01:54 -05:00
public boolean convertToInline(int index) {
if (index < 0 || index >= actionSlots.size()) return false;
return actionSlots.get(index).convertToInline();
2026-01-30 05:01:54 -05:00
}
2026-01-09 15:29:00 -05:00
public void clearAbilities() {
actionSlots.clear();
2026-01-09 15:29:00 -05:00
}
public boolean isEmpty() {
return actionSlots.isEmpty();
2026-01-09 15:29:00 -05:00
}
// ═══════════════════════════════════════════════════════════════════
2026-02-17 21:03:54 -05:00
// STATE QUERIES (NPC-specific)
2026-01-09 15:29:00 -05:00
// ═══════════════════════════════════════════════════════════════════
/**
* Check if ability is controlling NPC movement (AI pathfinding should be blocked).
* Called by AI tasks to determine if they should try to path.
* <p>
2026-01-09 15:29:00 -05:00
* Rules:
2026-01-28 15:16:28 -05:00
* - WINDUP phase: Block AI if movement is locked during windup
* - ACTIVE phase: Block AI if movement is locked during active OR hasAbilityMovement is true
* - DAZED phase: Always block AI (NPC is stunned)
2026-01-09 15:29:00 -05:00
*/
public boolean isAbilityControllingMovement() {
if (currentAbility == null || !currentAbility.isExecuting()) {
return false;
}
AbilityPhase phase = currentAbility.getPhase();
switch (phase) {
case WINDUP:
2026-01-28 15:16:28 -05:00
// During windup, only block if movement is locked during windup
// This allows NPC to keep chasing while winding up if not locked
return currentAbility.isMovementLockedDuringWindup();
2026-01-09 15:29:00 -05:00
case ACTIVE:
// During active phase, block if either:
2026-01-28 15:16:28 -05:00
// - movement is locked during active (stationary ability)
2026-01-09 15:29:00 -05:00
// - hasAbilityMovement is true (ability is moving the NPC itself)
2026-01-28 15:16:28 -05:00
return currentAbility.isMovementLockedDuringActive() || currentAbility.hasAbilityMovement();
2026-01-09 15:29:00 -05:00
case DAZED:
// During dazed phase, always block - NPC is stunned from interrupt
return true;
2026-01-09 15:29:00 -05:00
default:
return false;
}
}
2026-01-26 04:42:23 -05:00
/**
* Check if NPC's look direction should be locked to target.
2026-01-28 15:16:28 -05:00
* Only locks during ACTIVE phase when movement is locked during active.
* This allows the NPC to freely track target during WINDUP and DAZED.
2026-01-26 04:42:23 -05:00
*/
public boolean shouldLockLookDirection() {
if (currentAbility == null || !currentAbility.isExecuting()) {
return false;
}
// Only lock look direction during ACTIVE phase
if (currentAbility.getPhase() != AbilityPhase.ACTIVE) {
return false;
}
2026-01-28 15:16:28 -05:00
return currentAbility.isMovementLockedDuringActive();
2026-01-26 04:42:23 -05:00
}
2026-01-09 15:29:00 -05:00
/**
* Check if NPC should skip normal attacks during ability execution.
*/
public boolean shouldBlockAttack() {
if (currentAbility == null || !currentAbility.isExecuting()) {
return false;
}
AbilityPhase phase = currentAbility.getPhase();
return phase == AbilityPhase.WINDUP || phase == AbilityPhase.ACTIVE;
}
// ═══════════════════════════════════════════════════════════════════
2026-02-17 21:03:54 -05:00
// ROTATION CONTROL (NPC-specific: hit scan + 4-field rotation)
2026-01-26 19:03:48 -05:00
// ═══════════════════════════════════════════════════════════════════
/**
2026-02-06 16:28:38 -05:00
* Enable hit scan tracking for the given target.
*/
private void enableHitScan(EntityLivingBase target, float trackSpeed) {
2026-02-06 16:28:38 -05:00
if (!hitScanActive) {
hitScanActive = true;
npc.setBoolFlag(true, ROTATION_CONTROLLED_FLAG);
// Preserve tracked rotation across burst delays so the NPC doesn't snap
if (!preserveTrackedRotation) {
trackedYaw = npc.rotationYawHead;
trackedPitch = npc.rotationPitch;
}
preserveTrackedRotation = false;
2026-02-06 16:28:38 -05:00
}
hitScanTarget = target;
currentTrackSpeed = trackSpeed;
2026-02-06 16:28:38 -05:00
}
private static final double DEG_TO_RAD = Math.PI / 180.0;
private static final double RAD_TO_DEG = 180.0 / Math.PI;
2026-02-06 16:28:38 -05:00
/**
* 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.
2026-01-26 19:03:48 -05:00
*/
private void faceTarget(EntityLivingBase target, float trackSpeed) {
2026-02-06 16:28:38 -05:00
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) * RAD_TO_DEG);
float targetPitch = (float) (-(Math.atan2(dy, distXZ)) * RAD_TO_DEG);
2026-03-02 20:09:32 -05:00
if (trackSpeed <= 0) {
// Instant snap
2026-03-07 15:12:55 -05:00
trackedYaw = targetYaw;
trackedPitch = targetPitch;
2026-03-02 20:09:32 -05:00
npc.rotationYaw = targetYaw;
npc.rotationYawHead = targetYaw;
npc.renderYawOffset = targetYaw;
npc.rotationPitch = targetPitch;
npc.prevRotationYaw = targetYaw;
npc.prevRotationYawHead = targetYaw;
npc.prevRenderYawOffset = targetYaw;
npc.prevRotationPitch = targetPitch;
} else {
2026-03-07 15:12:55 -05:00
if (!trackLerpedThisTick) {
trackLerpedThisTick = true;
2026-03-02 20:09:32 -05:00
// 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);
2026-03-07 15:12:55 -05:00
float yawDiff = wrapAngle(targetYaw - trackedYaw);
float pitchDiff = targetPitch - trackedPitch;
2026-03-02 20:09:32 -05:00
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;
2026-03-07 15:12:55 -05:00
}
2026-03-02 20:09:32 -05:00
// Set prev to current before updating (for smooth client interpolation)
npc.prevRotationYaw = npc.rotationYaw;
npc.prevRotationYawHead = npc.rotationYawHead;
npc.prevRenderYawOffset = npc.renderYawOffset;
npc.prevRotationPitch = npc.rotationPitch;
2026-03-07 15:12:55 -05:00
npc.rotationYaw = trackedYaw;
npc.rotationYawHead = trackedYaw;
npc.renderYawOffset = trackedYaw;
npc.rotationPitch = trackedPitch;
2026-03-02 20:09:32 -05:00
}
}
/**
* Snap the NPC to face the target instantly (no delay).
*/
private void faceTarget(EntityLivingBase target) {
faceTarget(target, 0);
}
2026-02-06 16:28:38 -05:00
2026-03-02 20:09:32 -05:00
private static float wrapAngle(float angle) {
angle = angle % 360.0f;
if (angle >= 180.0f) angle -= 360.0f;
if (angle < -180.0f) angle += 360.0f;
return angle;
2026-02-06 16:28:38 -05:00
}
/**
* Apply rotation control after super.onLivingUpdate() and super.onUpdate().
* Handles both LOCKED (freeze at captured values) and TRACK (face target) modes.
2026-02-18 05:13:51 -05:00
* <p>
2026-02-06 16:28:38 -05:00
* Server: computes the correct rotation (locked values or target facing).
* Client: trusts the server-synced rotation values and prevents body smoothing override.
*/
public void applyRotationControl() {
if (npc.worldObj.isRemote) {
2026-02-06 16:28:38 -05:00
// Client: check single flag for any rotation control
if (!npc.getBoolFlag(ROTATION_CONTROLLED_FLAG)) {
rotationLocked = false;
return;
}
2026-02-06 16:28:38 -05:00
// Trust server rotation, prevent body smoothing from overriding
npc.prevRotationYaw = npc.rotationYaw;
npc.prevRotationYawHead = npc.rotationYawHead;
npc.renderYawOffset = npc.rotationYawHead;
npc.prevRenderYawOffset = npc.rotationYawHead;
npc.prevRotationPitch = npc.rotationPitch;
return;
}
// Server: apply the appropriate rotation
if (hitScanActive && hitScanTarget != null && !hitScanTarget.isDead) {
faceTarget(hitScanTarget, currentTrackSpeed);
2026-02-06 16:28:38 -05:00
} else if (rotationLocked) {
npc.rotationYaw = lockedYaw;
npc.rotationYawHead = lockedYawHead;
npc.renderYawOffset = lockedRenderYawOffset;
npc.rotationPitch = lockedPitch;
npc.prevRotationYaw = lockedYaw;
npc.prevRotationYawHead = lockedYawHead;
npc.prevRenderYawOffset = lockedRenderYawOffset;
npc.prevRotationPitch = lockedPitch;
}
2026-01-26 19:03:48 -05:00
}
/**
2026-02-06 16:28:38 -05:00
* Check if rotation is currently controlled (locked or tracking target).
* Used by AI tasks to determine if they should try to change NPC look direction.
2026-01-26 19:03:48 -05:00
*/
public boolean isRotationLocked() {
2026-02-06 16:28:38 -05:00
return rotationLocked || hitScanActive;
2026-01-26 19:03:48 -05:00
}
// ═══════════════════════════════════════════════════════════════════
2026-02-17 21:03:54 -05:00
// POSITION LOCKING (NPC-specific: data watcher + client-side logic)
// ═══════════════════════════════════════════════════════════════════
/**
* Apply locked position values to the NPC.
* Called AFTER super.onLivingUpdate() in EntityNPCInterface to override
* any position changes from AI pathfinding, physics, or entity collisions.
* Runs on both client and server.
*/
public void applyLockedPosition() {
if (npc.worldObj.isRemote) {
// Client-side: check flag via data watcher
boolean flagActive = npc.getBoolFlag(POSITION_LOCKED_FLAG);
if (!flagActive) {
positionLocked = false;
return;
}
// First tick the flag is active: capture current position
if (!positionLocked) {
lockedPosX = npc.posX;
lockedPosY = npc.posY;
lockedPosZ = npc.posZ;
positionLocked = true;
}
// Snap position back and set prev to prevent interpolation jitter
npc.setPosition(lockedPosX, lockedPosY, lockedPosZ);
npc.prevPosX = lockedPosX;
npc.prevPosY = lockedPosY;
npc.prevPosZ = lockedPosZ;
npc.motionX = 0;
npc.motionY = 0;
npc.motionZ = 0;
return;
}
// Server-side: use stored locked values
if (!positionLocked) {
return;
}
npc.setPosition(lockedPosX, lockedPosY, lockedPosZ);
npc.prevPosX = lockedPosX;
npc.prevPosY = lockedPosY;
npc.prevPosZ = lockedPosZ;
npc.motionX = 0;
npc.motionY = 0;
npc.motionZ = 0;
}
2026-01-26 19:03:48 -05:00
// ═══════════════════════════════════════════════════════════════════
2026-01-09 15:29:00 -05:00
// NBT SERIALIZATION
// ═══════════════════════════════════════════════════════════════════
public NBTTagCompound writeToNBT(NBTTagCompound compound) {
compound.setBoolean("AbilitiesEnabled", enabled);
2026-01-26 04:42:23 -05:00
compound.setInteger("AbilityMinCooldown", minCooldown);
compound.setInteger("AbilityMaxCooldown", maxCooldown);
2026-01-09 15:29:00 -05:00
NBTTagList actionList = new NBTTagList();
for (AbilityAction slot : actionSlots) {
actionList.appendTag(slot.writeNBT(true));
2026-01-09 15:29:00 -05:00
}
compound.setTag("AbilityActions", actionList);
2026-01-09 15:29:00 -05:00
2026-02-18 22:19:18 -05:00
// Active toggles (compound format with state)
NBTTagList toggleList = new NBTTagList();
2026-02-22 03:28:56 -05:00
for (Map.Entry<String, AbilityToggleEntry> entry : activeToggles.entrySet()) {
2026-02-18 22:19:18 -05:00
NBTTagCompound toggleNbt = new NBTTagCompound();
toggleNbt.setString("Key", entry.getKey());
toggleNbt.setInteger("State", entry.getValue().getState());
toggleList.appendTag(toggleNbt);
}
compound.setTag("ActiveToggles", toggleList);
2026-01-09 15:29:00 -05:00
return compound;
}
public void readFromNBT(NBTTagCompound compound) {
2026-02-24 01:15:37 -05:00
enabled = compound.hasKey("AbilitiesEnabled") ? compound.getBoolean("AbilitiesEnabled") : true;
2026-01-30 05:01:54 -05:00
minCooldown = compound.getInteger("AbilityMinCooldown");
maxCooldown = compound.getInteger("AbilityMaxCooldown");
2026-01-26 04:42:23 -05:00
actionSlots.clear();
if (compound.hasKey("AbilityActions")) {
// New unified format
NBTTagList actionList = compound.getTagList("AbilityActions", 10);
for (int i = 0; i < actionList.tagCount(); i++) {
AbilityAction slot = AbilityAction.fromNBT(actionList.getCompoundTagAt(i));
if (slot != null) {
actionSlots.add(slot);
}
}
} else {
// Legacy migration: read old separate lists
if (compound.hasKey("Abilities")) {
NBTTagList abilityList = compound.getTagList("Abilities", 10);
for (int i = 0; i < abilityList.tagCount(); i++) {
AbilityAction slot = AbilityAction.fromNBT(abilityList.getCompoundTagAt(i));
if (slot != null) {
actionSlots.add(slot);
}
}
}
if (compound.hasKey("ChainedAbilities")) {
NBTTagList chainList = compound.getTagList("ChainedAbilities", 8);
for (int i = 0; i < chainList.tagCount(); i++) {
String ref = chainList.getStringTagAt(i);
if (ref != null && !ref.isEmpty()) {
actionSlots.add(AbilityAction.chainReference(ref));
}
}
2026-01-09 15:29:00 -05:00
}
}
// Active toggles - restore state directly (no onToggle callback during load)
activeToggles.clear();
if (compound.hasKey("ActiveToggles")) {
2026-02-18 22:19:18 -05:00
NBTTagList toggleNbt = compound.getTagList("ActiveToggles", 10); // 10 = TAG_COMPOUND
for (int i = 0; i < toggleNbt.tagCount(); i++) {
2026-02-18 22:19:18 -05:00
NBTTagCompound entry = toggleNbt.getCompoundTagAt(i);
String key = entry.getString("Key");
int state = entry.hasKey("State") ? entry.getInteger("State") : 1;
setToggleEntryDirect(key, state);
}
}
2026-01-09 15:29:00 -05:00
}
}