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

1091 lines
42 KiB
Java
Raw Normal View History

2026-02-17 21:03:54 -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;
2026-02-17 21:03:54 -05:00
import kamkeel.npcs.controllers.data.ability.AbilityPhase;
import kamkeel.npcs.controllers.data.ability.ChainedAbility;
import kamkeel.npcs.controllers.data.ability.ChainedAbilityEntry;
import kamkeel.npcs.controllers.data.ability.ConcurrentSlot;
import kamkeel.npcs.controllers.data.ability.ToggleEntry;
2026-02-17 21:03:54 -05:00
import kamkeel.npcs.controllers.data.telegraph.TelegraphInstance;
import net.minecraft.entity.EntityLivingBase;
import net.minecraft.util.DamageSource;
import noppes.npcs.controllers.AnimationController;
import noppes.npcs.controllers.data.Animation;
2026-02-18 05:13:51 -05:00
import java.util.ArrayList;
import java.util.Iterator;
2026-02-18 05:13:51 -05:00
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
2026-02-17 21:03:54 -05:00
/**
* Shared base class for NPC and Player ability data management.
* Contains the common ability lifecycle logic: tick, phase transitions,
* animation/telegraph/sound management, cooldown state, and position/rotation locking.
* <p>
* Subclasses must implement abstract methods for entity-specific behavior:
* event firing, rotation/position application, telegraph packets, and cooldown rolling.
*
* @see DataAbilities - NPC implementation
* @see noppes.npcs.controllers.data.PlayerAbilityData - Player implementation
*/
public abstract class AbstractDataAbilities {
// ═══════════════════════════════════════════════════════════════════
// SHARED RUNTIME STATE (not saved to NBT)
// ═══════════════════════════════════════════════════════════════════
2026-02-18 05:13:51 -05:00
/**
* Currently executing ability (null if none)
*/
2026-02-17 21:03:54 -05:00
protected Ability currentAbility;
2026-02-18 05:13:51 -05:00
/**
* World time when cooldown ends
*/
2026-02-17 21:03:54 -05:00
protected long cooldownEndTime = 0;
2026-02-18 05:13:51 -05:00
/**
* Rotation lock state
*/
2026-02-17 21:03:54 -05:00
protected boolean rotationLocked = false;
protected float lockedYaw = 0;
protected float lockedPitch = 0;
2026-02-18 05:13:51 -05:00
/**
* Position lock state
*/
2026-02-17 21:03:54 -05:00
protected boolean positionLocked = false;
2026-02-19 03:12:59 -05:00
protected boolean wasFlyingAtLock = false;
2026-02-17 21:03:54 -05:00
protected double lockedPosX = 0;
protected double lockedPosY = 0;
protected double lockedPosZ = 0;
2026-02-18 05:13:51 -05:00
/**
* Chained ability execution state
*/
protected ChainedAbility currentChain;
protected int chainEntryIndex = -1;
protected int chainDelayRemaining = -1;
2026-02-18 05:13:51 -05:00
/**
* Set when interrupt already rolled cooldown (prevents double cooldown on NPC dazed completion).
*/
protected boolean interruptCooldownRolled = false;
/**
* Concurrent ability execution slots. Ticked independently alongside the primary ability.
* Concurrent abilities skip animations, sounds, and movement/rotation locks.
*/
protected List<ConcurrentSlot> concurrentSlots = new ArrayList<>();
2026-02-17 21:03:54 -05:00
// ═══════════════════════════════════════════════════════════════════
// ABSTRACT METHODS - Subclasses must implement
// ═══════════════════════════════════════════════════════════════════
2026-02-18 05:13:51 -05:00
/**
* Get the entity this ability data belongs to (NPC or Player).
*/
2026-02-17 21:03:54 -05:00
protected abstract EntityLivingBase getEntity();
2026-02-18 05:13:51 -05:00
/**
* Get the current target for ability execution.
*/
2026-02-17 21:03:54 -05:00
protected abstract EntityLivingBase getTarget();
2026-02-18 05:13:51 -05:00
/**
* Get the current world time.
*/
2026-02-17 21:03:54 -05:00
protected abstract long getWorldTime();
// -- Event Firing --
2026-02-18 05:13:51 -05:00
/**
* Fire tick event. Implementation differs between NPC (NpcAPI) and Player (ScriptController).
*/
2026-02-17 21:03:54 -05:00
protected abstract void fireTickEvent(Ability ability, EntityLivingBase target);
2026-02-18 05:13:51 -05:00
/**
* Fire execute event. Returns true if cancelled.
*/
2026-02-17 21:03:54 -05:00
protected abstract boolean fireExecuteEvent(Ability ability, EntityLivingBase target);
2026-02-18 05:13:51 -05:00
/**
* Fire complete event.
*/
2026-02-17 21:03:54 -05:00
protected abstract void fireCompleteEvent(Ability ability, EntityLivingBase target);
2026-02-18 05:13:51 -05:00
/**
* Fire interrupt event.
*/
2026-02-17 21:03:54 -05:00
protected abstract void fireInterruptEvent(Ability ability, EntityLivingBase target,
DamageSource source, float damage);
// -- Telegraph --
2026-02-18 05:13:51 -05:00
/**
* Spawn telegraphs for an ability.
*/
2026-02-17 21:03:54 -05:00
protected abstract void spawnTelegraph(Ability ability, EntityLivingBase target);
2026-02-18 05:13:51 -05:00
/**
* Remove telegraphs for an ability.
*/
2026-02-17 21:03:54 -05:00
protected abstract void removeTelegraph(Ability ability);
// -- Animation/Sound Data Access --
2026-02-18 05:13:51 -05:00
/**
* Set animation data on the entity.
*/
2026-02-17 21:03:54 -05:00
protected abstract void setAnimationData(Animation animation);
2026-02-18 05:13:51 -05:00
/**
* Clear animation data on the entity.
*/
2026-02-17 21:03:54 -05:00
protected abstract void clearAnimationData();
2026-02-18 05:13:51 -05:00
/**
* Play a sound at the entity's location.
*/
2026-02-17 21:03:54 -05:00
protected abstract void playAbilitySound(String sound);
// -- Rotation (subclass handles extra fields and application) --
2026-02-18 05:13:51 -05:00
/**
* Capture rotation values for locking. NPC captures 4 fields, Player captures 2.
*/
2026-02-17 21:03:54 -05:00
protected abstract void captureLockedRotation();
// -- Completion --
/**
* Roll cooldown after ability completes.
* NPC: random(min, max) + ability offset. Player: ability's base cooldownTicks.
*/
protected abstract void rollCooldown(Ability ability);
2026-02-18 05:13:51 -05:00
/**
* Additional completion logic (NPC clears lastTarget, Player clears key/target and syncs).
*/
2026-02-17 21:03:54 -05:00
protected abstract void onAbilityComplete();
/**
* Roll cooldown after a chained ability completes.
* NPC: random(min, max) + chain.cooldownTicks. Player: chain.cooldownTicks only.
*/
protected abstract void rollChainCooldown(ChainedAbility chain);
2026-02-17 21:03:54 -05:00
// -- Hooks (optional overrides) --
2026-02-18 05:13:51 -05:00
/**
* Called before onExecute in ACTIVE phase. NPC uses this for faceTarget/hitScan.
*/
protected void onPreExecute(Ability ability, EntityLivingBase target) {
}
2026-02-17 21:03:54 -05:00
2026-02-18 05:13:51 -05:00
/**
* Called after the phase switch. NPC uses this for hit scan updates and movement control.
*/
protected void onPostPhaseTick(Ability ability, EntityLivingBase target) {
}
2026-02-17 21:03:54 -05:00
2026-02-18 05:13:51 -05:00
/**
* Additional lock releases in BURST_DELAY. NPC releases hitScan.
*/
protected void onBurstDelayReleaseLocks() {
}
2026-02-17 21:03:54 -05:00
2026-02-18 05:13:51 -05:00
/**
* Hook for retargeting during chain execution when target dies. NPC overrides, Player returns null.
*/
protected EntityLivingBase retargetForChain() {
return null;
}
2026-02-17 21:03:54 -05:00
// ═══════════════════════════════════════════════════════════════════
// SHARED ANIMATION METHODS
// ═══════════════════════════════════════════════════════════════════
/**
* Play an animation on the entity.
*/
public void playAbilityAnimation(Animation animation) {
if (animation == null) return;
if (AnimationController.Instance == null) return;
setAnimationData(animation);
}
public void playAbilityAnimation(int animation) {
if (animation < 0) return;
if (AnimationController.Instance == null) return;
if (AnimationController.Instance.get(animation) == null) return;
playAbilityAnimation((Animation) AnimationController.Instance.get(animation));
}
public void playAbilityAnimation(String animation) {
if (animation.isEmpty()) return;
if (AnimationController.Instance == null) return;
if (AnimationController.Instance.get(animation, true) == null) return;
playAbilityAnimation((Animation) AnimationController.Instance.get(animation, true));
}
/**
* Stop any currently playing ability animation.
*/
protected void stopAbilityAnimation() {
clearAnimationData();
}
// ═══════════════════════════════════════════════════════════════════
// STATE QUERIES
// ═══════════════════════════════════════════════════════════════════
2026-02-18 05:13:51 -05:00
/**
* Check if an ability is currently executing.
*/
2026-02-17 21:03:54 -05:00
public boolean isExecutingAbility() {
return currentAbility != null && currentAbility.isExecuting();
}
2026-02-18 05:13:51 -05:00
/**
* Get the currently executing ability.
*/
2026-02-17 21:03:54 -05:00
public Ability getCurrentAbility() {
return currentAbility;
}
2026-02-18 05:13:51 -05:00
/**
* Check if a chained ability is currently executing.
*/
public boolean isExecutingChain() {
return currentChain != null;
}
2026-02-18 05:13:51 -05:00
/**
* Get the currently executing chained ability.
*/
public ChainedAbility getCurrentChain() {
return currentChain;
}
2026-02-17 21:03:54 -05:00
// ═══════════════════════════════════════════════════════════════════
// TOGGLE STATE (active toggles, independent of currentAbility)
// ═══════════════════════════════════════════════════════════════════
2026-02-18 05:13:51 -05:00
/**
2026-02-18 22:19:18 -05:00
* Currently active toggles. Key = ability key, Value = toggle entry with state and tick counter.
2026-02-18 05:13:51 -05:00
*/
protected Map<String, ToggleEntry> activeToggles = new LinkedHashMap<>();
/**
2026-02-18 22:19:18 -05:00
* Cycle a toggle ability to its next state.
* Off(0) -> State 1 -> State 2 -> ... -> State N -> Off(0)
2026-02-18 05:13:51 -05:00
*
* @param key The ability key (e.g., "npcdbc:ki_fist")
2026-02-18 22:19:18 -05:00
* @return The new state (0 = off, 1+ = active state number)
*/
public int toggleAbility(String key) {
ToggleEntry existing = activeToggles.get(key);
if (existing != null) {
int currentState = existing.getState();
int maxStates = existing.getAbility().getToggleStates();
if (currentState < maxStates) {
return cycleToggleState(key, existing, currentState, currentState + 1);
} else {
deactivateToggle(key);
return 0;
}
} else {
2026-02-18 22:19:18 -05:00
return activateToggle(key, 1) ? 1 : 0;
}
}
/**
2026-02-18 22:19:18 -05:00
* Get the current toggle state for an ability.
* @return 0 if not active, 1+ for active state
*/
public int getToggleState(String key) {
ToggleEntry entry = activeToggles.get(key);
return entry != null ? entry.getState() : 0;
}
/**
* Set a toggle to a specific state. 0 = deactivate, 1+ = specific state.
*/
public void setToggleState(String key, int state) {
if (state <= 0) {
if (activeToggles.containsKey(key)) {
deactivateToggle(key);
}
return;
}
ToggleEntry existing = activeToggles.get(key);
if (existing != null) {
int currentState = existing.getState();
if (currentState != state) {
cycleToggleState(key, existing, currentState, state);
}
} else {
activateToggle(key, state);
}
}
/**
2026-02-18 22:19:18 -05:00
* Check if a toggle ability is currently active (any state > 0).
*/
public boolean isAbilityToggled(String key) {
return activeToggles.containsKey(key);
}
/**
* Get all active toggle keys.
*/
public Set<String> getActiveToggleKeys() {
return new LinkedHashSet<>(activeToggles.keySet());
}
2026-02-18 22:19:18 -05:00
private boolean activateToggle(String key, int state) {
Ability ability = AbilityController.Instance != null
? AbilityController.Instance.resolveAbility(key) : null;
if (ability == null || !ability.isToggleable()) return false;
2026-02-18 22:19:18 -05:00
if (state < 1 || state > ability.getToggleStates()) state = 1;
2026-02-18 22:19:18 -05:00
if (fireToggleEvent(ability, 0, state)) return false;
ToggleEntry entry = new ToggleEntry(ability, state);
activeToggles.put(key, entry);
ability.onToggleOn(getEntity());
2026-02-18 22:19:18 -05:00
ability.onToggleStateChanged(getEntity(), 0, state);
onToggleStateChanged(key, true, state);
return true;
}
2026-02-18 22:19:18 -05:00
private int cycleToggleState(String key, ToggleEntry entry, int oldState, int newState) {
Ability ability = entry.getAbility();
if (newState < 1 || newState > ability.getToggleStates()) return oldState;
if (fireToggleEvent(ability, oldState, newState)) return oldState;
entry.setState(newState);
ability.onToggleStateChanged(getEntity(), oldState, newState);
onToggleStateChanged(key, true, newState);
return newState;
}
private void deactivateToggle(String key) {
2026-02-18 22:19:18 -05:00
ToggleEntry entry = activeToggles.get(key);
if (entry == null) return;
int oldState = entry.getState();
if (fireToggleEvent(entry.getAbility(), oldState, 0)) return;
activeToggles.remove(key);
entry.getAbility().onToggleOff(getEntity());
entry.getAbility().onToggleStateChanged(getEntity(), oldState, 0);
onToggleStateChanged(key, false, 0);
}
/**
* Tick all active toggles. Call from tick() each game tick.
* Toggles with hasActiveToggle=true get their onToggleTick() called.
* If onToggleTick returns false, the toggle is auto-deactivated.
2026-02-18 22:19:18 -05:00
* Every 10 ticks, fires a toggle update event for ALL active toggles.
*/
protected void tickActiveToggles() {
if (activeToggles.isEmpty()) return;
EntityLivingBase entity = getEntity();
List<String> toRemove = null;
for (Map.Entry<String, ToggleEntry> mapEntry : activeToggles.entrySet()) {
ToggleEntry entry = mapEntry.getValue();
entry.incrementTick();
if (entry.getAbility().hasActiveToggle()) {
2026-02-18 22:19:18 -05:00
if (!entry.getAbility().onToggleTick(entity, entry.getTickCount(), entry.getState())) {
if (toRemove == null) toRemove = new ArrayList<>();
toRemove.add(mapEntry.getKey());
continue;
}
}
if (entry.getTickCount() % 10 == 0) {
boolean enabled = fireToggleUpdateEvent(entry.getAbility(), entry.getTickCount(), entry.getState());
if (!enabled) {
if (toRemove == null) toRemove = new ArrayList<>();
toRemove.add(mapEntry.getKey());
}
}
}
if (toRemove != null) {
for (String key : toRemove) {
ToggleEntry entry = activeToggles.remove(key);
if (entry != null) {
2026-02-18 22:19:18 -05:00
int oldState = entry.getState();
entry.getAbility().onToggleOff(entity);
2026-02-18 22:19:18 -05:00
entry.getAbility().onToggleStateChanged(entity, oldState, 0);
onToggleStateChanged(key, false, 0);
}
}
}
}
2026-02-18 05:13:51 -05:00
/**
* Hook for subclass to react to toggle state changes (sync packets, script events).
*/
2026-02-18 22:19:18 -05:00
protected void onToggleStateChanged(String key, boolean active, int state) {
}
/**
* Fire a toggle event before state change.
* @param oldState Previous state (0 = off)
* @param newState Target state (0 = off)
* @return true if the event was canceled
*/
protected boolean fireToggleEvent(Ability ability, int oldState, int newState) {
return false;
}
/**
* Fire a toggle update event every 10 ticks.
* @return true if the toggle should remain active, false to force-deactivate
*/
protected boolean fireToggleUpdateEvent(Ability ability, int tick, int state) {
return true;
2026-02-18 05:13:51 -05:00
}
/**
* Clear all active toggles (e.g., on death/reset).
2026-02-18 22:19:18 -05:00
* Calls onToggleOff and onToggleStateChanged for each active toggle.
*/
protected void clearActiveToggles() {
if (activeToggles.isEmpty()) return;
EntityLivingBase entity = getEntity();
2026-02-18 22:19:18 -05:00
for (Map.Entry<String, ToggleEntry> mapEntry : activeToggles.entrySet()) {
ToggleEntry entry = mapEntry.getValue();
entry.getAbility().onToggleOff(entity);
2026-02-18 22:19:18 -05:00
entry.getAbility().onToggleStateChanged(entity, entry.getState(), 0);
}
activeToggles.clear();
}
/**
* Add a toggle entry directly without calling onToggleOn.
* Used for client-side sync and NBT restoration.
2026-02-18 22:19:18 -05:00
* @param key The ability key
* @param state The toggle state (0 = remove, 1+ = set at state)
*/
2026-02-18 22:19:18 -05:00
public void setToggleEntryDirect(String key, int state) {
if (state > 0) {
Ability ability = AbilityController.Instance != null
? AbilityController.Instance.resolveAbility(key) : null;
if (ability != null && ability.isToggleable()) {
2026-02-18 22:19:18 -05:00
activeToggles.put(key, new ToggleEntry(ability, state));
}
} else {
activeToggles.remove(key);
}
}
2026-02-17 21:03:54 -05:00
// ═══════════════════════════════════════════════════════════════════
// COOLDOWN MANAGEMENT
// ═══════════════════════════════════════════════════════════════════
2026-02-18 05:13:51 -05:00
/**
* Check if on cooldown.
*/
2026-02-17 21:03:54 -05:00
public boolean isOnCooldown() {
return getWorldTime() < cooldownEndTime;
}
2026-02-18 05:13:51 -05:00
/**
* Get remaining cooldown ticks.
*/
2026-02-17 21:03:54 -05:00
public long getRemainingCooldown() {
long remaining = cooldownEndTime - getWorldTime();
return remaining > 0 ? remaining : 0;
}
2026-02-18 05:13:51 -05:00
/**
* Reset cooldown (allow immediate ability use).
*/
2026-02-17 21:03:54 -05:00
public void resetCooldown() {
cooldownEndTime = 0;
}
2026-02-18 05:13:51 -05:00
/**
* Set the cooldown end time directly.
*/
2026-02-18 04:02:51 -05:00
public void setCooldownEndTime(long endTime) {
cooldownEndTime = endTime;
}
2026-02-17 21:03:54 -05:00
// ═══════════════════════════════════════════════════════════════════
// POSITION LOCKING
// ═══════════════════════════════════════════════════════════════════
/**
* Capture current position to lock entity in place.
*/
protected void captureLockedPosition() {
EntityLivingBase entity = getEntity();
lockedPosX = entity.posX;
lockedPosY = entity.posY;
lockedPosZ = entity.posZ;
positionLocked = true;
2026-02-19 03:12:59 -05:00
// Record flight state at lock time so airborne players stay at their height
// even if their flight system deactivates during input suppression
wasFlyingAtLock = entity instanceof net.minecraft.entity.player.EntityPlayer
&& AbilityController.Instance != null
&& AbilityController.Instance.isPlayerFlying((net.minecraft.entity.player.EntityPlayer) entity);
2026-02-17 21:03:54 -05:00
onPositionLockChanged(true);
}
/**
* Release position lock.
*/
protected void releaseLockedPosition() {
positionLocked = false;
2026-02-19 03:12:59 -05:00
wasFlyingAtLock = false;
2026-02-17 21:03:54 -05:00
onPositionLockChanged(false);
}
2026-02-19 03:12:59 -05:00
/**
* Whether the entity was flying when the position lock was captured.
*/
public boolean wasFlyingAtLock() {
return wasFlyingAtLock;
}
2026-02-18 05:13:51 -05:00
/**
* Hook for subclass to react to position lock state changes (e.g., NPC sets data watcher flag).
*/
protected void onPositionLockChanged(boolean locked) {
}
2026-02-17 21:03:54 -05:00
// ═══════════════════════════════════════════════════════════════════
// ROTATION LOCKING
// ═══════════════════════════════════════════════════════════════════
/**
* Release rotation control.
*/
protected void releaseRotationControl() {
rotationLocked = false;
onRotationLockChanged(false);
}
2026-02-18 05:13:51 -05:00
/**
* Hook for subclass to react to rotation lock state changes (e.g., NPC sets flag, clears hitScan).
*/
protected void onRotationLockChanged(boolean locked) {
}
2026-02-17 21:03:54 -05:00
// ═══════════════════════════════════════════════════════════════════
// CORE TICK - Phase transition logic shared between NPC and Player
// ═══════════════════════════════════════════════════════════════════
/**
* Tick the currently executing ability. Handles all phase transitions,
* telegraph/animation/sound lifecycle, and burst management.
* <p>
* This is the core shared logic between DataAbilities and PlayerAbilityData.
* Subclass-specific behavior is injected via abstract methods and hooks.
*/
protected void tickCurrentAbility() {
// Handle chain delay between entries
if (chainDelayRemaining > 0) {
chainDelayRemaining--;
if (chainDelayRemaining <= 0) {
EntityLivingBase chainTarget = getTarget();
// Check if target is dead and retarget
if (currentChain != null && chainTarget != null && chainTarget.isDead) {
chainTarget = retargetForChain();
if (chainTarget == null) {
completeChain();
return;
}
}
if (startChainEntry(chainTarget)) {
launchConsecutiveConcurrentEntries(chainTarget);
}
}
// Tick concurrent slots even during chain delay
tickConcurrentSlots();
return;
}
2026-02-17 21:03:54 -05:00
EntityLivingBase entity = getEntity();
EntityLivingBase target = getTarget();
AbilityPhase oldPhase = currentAbility.getPhase();
// Tick advances time and possibly changes phase
boolean phaseChanged = currentAbility.tick();
// Fire tick event
fireTickEvent(currentAbility, target);
// Fire extender tick hook (e.g., per-tick resource drain)
if (!AbilityController.Instance.fireOnAbilityTick(currentAbility, entity, target,
2026-02-18 05:13:51 -05:00
currentAbility.getPhase(), currentAbility.getCurrentTick())) {
2026-02-17 21:03:54 -05:00
interruptCurrentAbility(null, 0);
return;
}
// Handle phase-specific logic
switch (currentAbility.getPhase()) {
case WINDUP:
if (phaseChanged && oldPhase == AbilityPhase.BURST_DELAY) {
// Burst replay: re-enter windup - set up locks, telegraph, sound, animation
if (currentAbility.isRotationLockedDuringWindup()) {
captureLockedRotation();
}
if (currentAbility.isMovementLockedDuringWindup() && !currentAbility.hasAbilityMovement()) {
captureLockedPosition();
}
spawnTelegraph(currentAbility, target);
playAbilitySound(currentAbility.getWindUpSound());
playAbilityAnimation(currentAbility.getWindUpAnimation());
}
currentAbility.onWindUpTick(entity, target, currentAbility.getCurrentTick());
break;
case ACTIVE:
if (phaseChanged && (oldPhase == AbilityPhase.WINDUP || oldPhase == AbilityPhase.BURST_DELAY)) {
// Just entered ACTIVE phase - lock telegraph positions
for (TelegraphInstance telegraph : currentAbility.getTelegraphInstances()) {
telegraph.lockPosition();
}
// Remove telegraph unless the ability keeps it during active phase
if (!currentAbility.keepTelegraphDuringActive()) {
removeTelegraph(currentAbility);
}
// Handle rotation control transition from WINDUP to ACTIVE
if (currentAbility.isRotationLockedDuringActive()) {
if (!rotationLocked) {
captureLockedRotation();
}
} else if (rotationLocked) {
releaseRotationControl();
}
// Handle position lock transition from WINDUP to ACTIVE
if (currentAbility.isMovementLockedDuringActive() && !currentAbility.hasAbilityMovement()) {
if (!positionLocked) {
captureLockedPosition();
}
} else if (positionLocked) {
releaseLockedPosition();
}
// Play active sound and animation
if (!currentAbility.keepTelegraphDuringActive()) {
playAbilitySound(currentAbility.getActiveSound());
}
playAbilityAnimation(currentAbility.getActiveAnimation());
// Pre-execute hook (NPC: snap to face target for hit scan)
onPreExecute(currentAbility, target);
// Fire execute event (cancelable)
if (fireExecuteEvent(currentAbility, target)) {
2026-02-18 04:02:51 -05:00
// Script cancelled — abort cleanly instead of leaving stuck in ACTIVE
currentAbility.interrupt();
handleAbilityCompletion(target);
return;
2026-02-17 21:03:54 -05:00
}
// Call onExecute
currentAbility.onExecute(entity, target);
// Check if ability completed during onExecute
if (currentAbility.getPhase() == AbilityPhase.IDLE) {
handleAbilityCompletion(target);
return;
}
}
currentAbility.onActiveTick(entity, target, currentAbility.getCurrentTick());
// Check if ability completed during onActiveTick
if (currentAbility.getPhase() == AbilityPhase.IDLE) {
handleAbilityCompletion(target);
return;
}
// Auto-complete for burst overlap mode only.
// Non-overlap waits for all entities to die naturally (via allDead check in onActiveTick).
2026-02-17 21:03:54 -05:00
if (currentAbility.isBurstEnabled()
&& currentAbility.isBurstOverlap()
2026-02-17 21:03:54 -05:00
&& currentAbility.getBurstIndex() < currentAbility.getBurstAmount()
&& currentAbility.getPhase() == AbilityPhase.ACTIVE
&& currentAbility.isReadyForBurstCompletion(currentAbility.getCurrentTick())) {
currentAbility.signalCompletion();
}
// Check if ability entered burst delay
if (currentAbility.getPhase() == AbilityPhase.BURST_DELAY) {
if (rotationLocked) releaseRotationControl();
if (positionLocked) releaseLockedPosition();
onBurstDelayReleaseLocks();
}
break;
case BURST_DELAY:
// Free movement and rotation during burst delay
if (rotationLocked) releaseRotationControl();
if (positionLocked) releaseLockedPosition();
break;
case DAZED:
// Dazed phase - just wait for phase transition
break;
case IDLE:
// Ability completed (reached via tick() from DAZED)
handleAbilityCompletion(target);
break;
}
// Post-phase hook (NPC: hit scan + movement control)
onPostPhaseTick(currentAbility, target);
// Tick concurrent slots independently
tickConcurrentSlots();
2026-02-17 21:03:54 -05:00
}
// ═══════════════════════════════════════════════════════════════════
// ABILITY COMPLETION
// ═══════════════════════════════════════════════════════════════════
/**
* Handle ability completion. Called when ability phase becomes IDLE.
* If a chain is active, advances to the next entry instead of completing.
2026-02-17 21:03:54 -05:00
*/
protected void handleAbilityCompletion(EntityLivingBase target) {
if (currentAbility == null) return;
// Remove any remaining telegraphs
removeTelegraph(currentAbility);
// Fire extender complete hook
AbilityController.Instance.fireOnAbilityComplete(currentAbility, getEntity(), target, false);
// Call onComplete callback
currentAbility.onComplete(getEntity(), target);
// Fire complete event
fireCompleteEvent(currentAbility, target);
// Release locks
releaseRotationControl();
releaseLockedPosition();
stopAbilityAnimation();
// Chain mode: advance to next entry instead of completing
if (currentChain != null) {
// AFTER semantics: delay from the CURRENT (just-completed) entry
ChainedAbilityEntry completedEntry = currentChain.getEntries().get(chainEntryIndex);
int delay = completedEntry.getDelayTicks();
chainEntryIndex++;
// Skip any consecutive concurrent entries — launch them to concurrent slots
launchConsecutiveConcurrentEntries(target);
if (chainEntryIndex < currentChain.getEntries().size()) {
// Check if target died and retarget
if (target != null && target.isDead) {
target = retargetForChain();
if (target == null) {
completeChain();
return;
}
}
// Enforce minimum 1-tick delay between chain entries
chainDelayRemaining = Math.max(1, delay);
currentAbility = null;
return;
}
// All entries complete
completeChain();
return;
}
// Normal (non-chain) completion
if (interruptCooldownRolled) {
// Cooldown was already rolled during interrupt (e.g., chain interrupted, NPC ticked through DAZED)
interruptCooldownRolled = false;
} else {
rollCooldown(currentAbility);
}
2026-02-17 21:03:54 -05:00
onAbilityComplete();
}
// ═══════════════════════════════════════════════════════════════════
// EXECUTE IMMEDIATE
// ═══════════════════════════════════════════════════════════════════
/**
* Execute an ability immediately (no windup). Called when windUpTicks is 0.
*/
protected void executeImmediate(Ability ability, EntityLivingBase target) {
// Play active sound and animation
playAbilitySound(ability.getActiveSound());
playAbilityAnimation(ability.getActiveAnimation());
// Fire execute event (cancelable)
if (fireExecuteEvent(ability, target)) {
2026-02-18 04:02:51 -05:00
// Script cancelled — abort cleanly instead of leaving stuck in ACTIVE
ability.interrupt();
handleAbilityCompletion(target);
return;
2026-02-17 21:03:54 -05:00
}
// Call onExecute
ability.onExecute(getEntity(), target);
// Check if ability completed during onExecute
if (ability.getPhase() == AbilityPhase.IDLE) {
handleAbilityCompletion(target);
}
}
// ═══════════════════════════════════════════════════════════════════
// INTERRUPTION
// ═══════════════════════════════════════════════════════════════════
/**
* Interrupt the currently executing ability.
* Shared interrupt logic: remove telegraph, play dazed animation,
* release locks, fire events, call ability.interrupt().
*/
public void interruptCurrentAbility(DamageSource source, float damage) {
if (currentAbility != null && currentAbility.isExecuting()) {
// Remove telegraph
removeTelegraph(currentAbility);
// Fire extender complete hook (interrupted)
AbilityController.Instance.fireOnAbilityComplete(currentAbility, getEntity(), getTarget(), true);
// Fire interrupt event
fireInterruptEvent(currentAbility, getTarget(), source, damage);
// Call ability callbacks
currentAbility.onInterrupt(getEntity(), source, damage);
currentAbility.interrupt();
// Play dazed animation
stopAbilityAnimation();
playAbilityAnimation(currentAbility.getDazedAnimation());
// Release locks
releaseRotationControl();
releaseLockedPosition();
// If chain was active, roll chain cooldown and clear chain state
if (currentChain != null) {
rollChainCooldown(currentChain);
interruptCooldownRolled = true;
currentChain = null;
chainEntryIndex = -1;
chainDelayRemaining = -1;
}
// Interrupt all concurrent slots
interruptConcurrentSlots();
2026-02-17 21:03:54 -05:00
// Let subclass handle cleanup
onInterruptComplete();
}
}
/**
* Hook called after interrupt completes. Subclass handles clearing state and syncing.
* NPC: keeps currentAbility (ticks through DAZED). Player: clears immediately.
*/
2026-02-18 05:13:51 -05:00
protected void onInterruptComplete() {
}
// ═══════════════════════════════════════════════════════════════════
// CHAINED ABILITY EXECUTION
// ═══════════════════════════════════════════════════════════════════
/**
* Start executing a chained ability. Sets up chain state and starts the first entry.
*
* @param chain The chained ability to execute (should be a deep copy)
* @param target The initial target
* @return true if the chain was started
*/
protected boolean startChain(ChainedAbility chain, EntityLivingBase target) {
if (chain == null || chain.getEntries().isEmpty()) return false;
currentChain = chain;
chainEntryIndex = 0;
chainDelayRemaining = -1;
// AFTER semantics: no delay before first entry (delay applies after each entry completes)
boolean started = startChainEntry(target);
if (started) {
launchConsecutiveConcurrentEntries(target);
}
return started;
}
/**
* Start the current chain entry's ability.
*/
protected boolean startChainEntry(EntityLivingBase target) {
if (currentChain == null || chainEntryIndex < 0 || chainEntryIndex >= currentChain.getEntries().size()) {
completeChain();
return false;
}
ChainedAbilityEntry entry = currentChain.getEntries().get(chainEntryIndex);
2026-02-18 04:02:51 -05:00
Ability ability = entry.resolve();
if (ability == null) {
// Broken reference - complete chain
completeChain();
return false;
}
// Validate the resolved ability's UserType matches the chain's allowed context.
// The chain's own UserType was already checked at selection time, but individual
// abilities may have stricter restrictions (e.g., PLAYER_ONLY in an NPC chain).
if (!ability.getAllowedBy().allowsNpc() && getEntity() instanceof noppes.npcs.entity.EntityNPCInterface) {
// Skip this entry - ability doesn't allow NPCs
completeChain();
return false;
}
if (!ability.getAllowedBy().allowsPlayer() && getEntity() instanceof net.minecraft.entity.player.EntityPlayer) {
// Skip this entry - ability doesn't allow players
completeChain();
return false;
}
// If windUpAll=false, only the first ability windups; subsequent abilities skip windup
if (!currentChain.isWindUpAll() && chainEntryIndex > 0) {
ability.setWindUpTicks(0);
}
// Start the ability (skip its own conditions - chain conditions were already checked)
currentAbility = ability;
ability.start(target);
if (ability.getPhase() == AbilityPhase.ACTIVE) {
// Windup was 0 - capture locks for immediate active phase, then execute
if (ability.isRotationLockedDuringActive()) {
captureLockedRotation();
}
if (ability.isMovementLockedDuringActive() && !ability.hasAbilityMovement()) {
captureLockedPosition();
}
executeImmediate(ability, target);
} else {
// Normal windup flow
if (ability.isRotationLockedDuringWindup()) {
captureLockedRotation();
}
if (ability.isMovementLockedDuringWindup() && !ability.hasAbilityMovement()) {
captureLockedPosition();
}
spawnTelegraph(ability, target);
playAbilitySound(ability.getWindUpSound());
playAbilityAnimation(ability.getWindUpAnimation());
}
return true;
}
// ═══════════════════════════════════════════════════════════════════
// CONCURRENT SLOT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════
/**
* Tick all active concurrent slots. Remove completed ones.
* Called from tickCurrentAbility() and during chain delay countdown.
*/
protected void tickConcurrentSlots() {
if (concurrentSlots.isEmpty()) return;
EntityLivingBase entity = getEntity();
EntityLivingBase target = getTarget();
Iterator<ConcurrentSlot> it = concurrentSlots.iterator();
while (it.hasNext()) {
ConcurrentSlot slot = it.next();
slot.tick(entity, target);
if (slot.isCompleted()) {
it.remove();
}
}
}
/**
* After starting a primary chain entry, scan forward and launch any consecutive
* concurrent entries (with delay=0) to concurrent slots immediately.
* This enables simultaneous execution of Effect abilities alongside the primary.
*/
protected void launchConsecutiveConcurrentEntries(EntityLivingBase target) {
if (currentChain == null) return;
List<ChainedAbilityEntry> entries = currentChain.getEntries();
while (chainEntryIndex + 1 < entries.size()) {
ChainedAbilityEntry nextEntry = entries.get(chainEntryIndex + 1);
Ability resolved = nextEntry.resolve();
if (resolved == null || !resolved.isConcurrentCapable()
|| !nextEntry.isConcurrentEnabled()) break;
// Only auto-launch concurrent entries with 0 delay
if (nextEntry.getDelayTicks() > 0) break;
// Advance past this entry in the chain (it runs concurrently, not sequentially)
chainEntryIndex++;
// Deep copy the ability so it has its own state
Ability concurrentCopy = AbilityController.Instance.fromNBT(resolved.writeNBT());
if (concurrentCopy == null) continue;
ConcurrentSlot slot = new ConcurrentSlot(concurrentCopy);
concurrentSlots.add(slot);
slot.start(getEntity(), target);
}
}
/**
* Interrupt all active concurrent slots. Called on ability interrupt or combat reset.
*/
protected void interruptConcurrentSlots() {
for (ConcurrentSlot slot : concurrentSlots) {
slot.interrupt();
}
concurrentSlots.clear();
}
/**
* Complete the chain execution. Rolls chain cooldown and clears all chain state.
* NOTE: Does NOT clear concurrent slots — they outlive the chain and tick independently.
*/
protected void completeChain() {
if (currentChain != null) {
rollChainCooldown(currentChain);
}
currentChain = null;
chainEntryIndex = -1;
chainDelayRemaining = -1;
currentAbility = null;
onAbilityComplete();
}
2026-02-17 21:03:54 -05:00
}