From 3e58013ad66f219e6e4cba322523d6c1f32ba0e6 Mon Sep 17 00:00:00 2001 From: Kam Date: Sat, 21 Feb 2026 05:09:00 -0500 Subject: [PATCH] =?UTF-8?q?Various=20Fixes=20=F0=9F=90=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/renderer/RenderEnergyPanel.java | 14 ++++- .../controllers/data/ability/Ability.java | 55 +++++++++++++++++- .../data/ability/type/AbilityZone.java | 17 ++++++ .../type/energy/AbilityEnergyBarrier.java | 16 ++++++ .../type/energy/AbilityEnergyProjectile.java | 19 +++++++ .../ability/type/energy/AbilitySweeper.java | 21 +++++++ .../npcs/entity/EntityAbilityZone.java | 13 ++++- .../npcs/entity/EntityEnergyPanel.java | 7 +++ .../packets/player/SpecialKeyStatePacket.java | 11 ++-- .../noppes/npcs/AbstractDataAbilities.java | 56 +++++++++++++++++++ .../controllers/data/PlayerAbilityData.java | 47 ++++++++++++++++ .../assets/customnpcs/lang/en_US.lang | 2 + 12 files changed, 268 insertions(+), 10 deletions(-) diff --git a/src/main/java/kamkeel/npcs/client/renderer/RenderEnergyPanel.java b/src/main/java/kamkeel/npcs/client/renderer/RenderEnergyPanel.java index 38bdd57b2..24c9ea241 100644 --- a/src/main/java/kamkeel/npcs/client/renderer/RenderEnergyPanel.java +++ b/src/main/java/kamkeel/npcs/client/renderer/RenderEnergyPanel.java @@ -21,9 +21,17 @@ public class RenderEnergyPanel extends RenderEnergyBarrier { float width = panel.getPanelData().panelWidth; float height = panel.getPanelData().panelHeight; - float panelYaw = panel.getPanelYaw(); float healthPercent = panel.getHealthPercent(); + // Interpolate panel yaw for smooth rotation between ticks + float prevYaw = panel.getPrevPanelYaw(); + float currentYaw = panel.getPanelYaw(); + float yawDelta = currentYaw - prevYaw; + // Wrap angle delta to -180..180 for shortest rotation path + while (yawDelta > 180.0f) yawDelta -= 360.0f; + while (yawDelta < -180.0f) yawDelta += 360.0f; + float renderYaw = prevYaw + yawDelta * partialTicks; + setupRenderState(); GL11.glPushMatrix(); @@ -34,8 +42,8 @@ public class RenderEnergyPanel extends RenderEnergyBarrier { renderAttachedLightning(panel, 0.9f, Math.max(width, height) * 0.5f); } - // Rotate panel to face the correct direction - GL11.glRotatef(-panelYaw, 0.0f, 1.0f, 0.0f); + // Rotate panel to face the correct direction (interpolated) + GL11.glRotatef(-renderYaw, 0.0f, 1.0f, 0.0f); // Hit flash float flashAlpha = computeFlashAlpha(panel); diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java index 5517caef2..d7ca77eac 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java @@ -114,6 +114,10 @@ public abstract class Ability implements IAbility, IAbilityAction { // instead of sharing the global cooldown with other abilities protected boolean perAbilityCooldown = false; + // Free on Cast: if true, ability enters cooldown immediately after entities are spawned, + // allowing the caster to use other abilities while summoned entities remain active + protected boolean freeOnCast = false; + // ═══════════════════════════════════════════════════════════════════ // BUILT-IN (set in constructor via configureAsBuiltIn, NOT persisted) // ═══════════════════════════════════════════════════════════════════ @@ -255,6 +259,22 @@ public abstract class Ability implements IAbility, IAbilityAction { return false; } + /** + * Whether this ability type supports the Free on Cast option. + * Override to return true for abilities that spawn persistent entities (projectiles, barriers, zones, sweeper). + */ + public boolean allowFreeOnCast() { + return false; + } + + /** + * Detach all spawned entities (let them live independently) without killing them. + * Called during signalCompletion() when freeOnCast is enabled. + * Override in subclasses to null entity references without calling setDead(). + */ + public void detach() { + } + // ═══════════════════════════════════════════════════════════════════ // TOGGLE CALLBACKS (optional overrides for toggleable abilities) // ═══════════════════════════════════════════════════════════════════ @@ -612,6 +632,10 @@ public abstract class Ability implements IAbility, IAbilityAction { FieldDef.intField("ability.cooldownTicks", this::getCooldownTicks, this::setCooldownTicks).range(0, 10000), FieldDef.boolField("ability.perAbilityCooldown", this::isPerAbilityCooldown, this::setPerAbilityCooldown) ).tab("General")); + if (allowFreeOnCast()) { + defs.add(FieldDef.boolField("ability.freeOnCast", this::isFreeOnCast, this::setFreeOnCast) + .tab("General").hover("ability.hover.freeOnCast")); + } defs.add(FieldDef.section("ability.section.movement").tab("General")); defs.add(FieldDef.stringEnumField("ability.lockMovement", LockMovementType.getDisplayKeys(), () -> this.getLockMovement().getDisplayKey(), @@ -1072,7 +1096,12 @@ public abstract class Ability implements IAbility, IAbilityAction { // Final completion - let burst entities die naturally (don't force-kill) burstEntities.clear(); - cleanup(); + if (freeOnCast) { + // Free on Cast: detach entities (let them live independently) instead of killing + detach(); + } else { + cleanup(); + } phase = AbilityPhase.IDLE; currentTick = 0; return true; @@ -1101,6 +1130,20 @@ public abstract class Ability implements IAbility, IAbilityAction { telegraphInstances.clear(); } + /** + * Cancel this ability (voluntary player action). Goes directly to IDLE, skipping DAZED. + * Entities are killed via cleanup(). Unlike interrupt(), this never enters DAZED phase. + */ + public void cancel() { + cleanupBurstEntities(); + cleanup(); + phase = AbilityPhase.IDLE; + currentTick = 0; + burstIndex = 0; + currentTarget = null; + telegraphInstances.clear(); + } + /** * Check if executing */ @@ -1298,6 +1341,7 @@ public abstract class Ability implements IAbility, IAbilityAction { nbt.setInteger("allowedBy", allowedBy.ordinal()); nbt.setBoolean("ignoreCooldown", ignoreCooldown); nbt.setBoolean("perAbilityCooldown", perAbilityCooldown); + nbt.setBoolean("freeOnCast", freeOnCast); // Toggle nbt.setInteger("toggleStates", toggleStates); @@ -1380,6 +1424,7 @@ public abstract class Ability implements IAbility, IAbilityAction { allowedBy = UserType.fromOrdinal(nbt.getInteger("allowedBy")); ignoreCooldown = nbt.getBoolean("ignoreCooldown"); perAbilityCooldown = nbt.getBoolean("perAbilityCooldown"); + freeOnCast = nbt.getBoolean("freeOnCast"); // Toggle toggleStates = nbt.getInteger("toggleStates"); @@ -1976,6 +2021,14 @@ public abstract class Ability implements IAbility, IAbilityAction { this.perAbilityCooldown = perAbilityCooldown; } + public boolean isFreeOnCast() { + return freeOnCast; + } + + public void setFreeOnCast(boolean freeOnCast) { + this.freeOnCast = freeOnCast; + } + public boolean isToggleable() { return toggleStates > 0; } diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityZone.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityZone.java index 7f2a92329..a1d1af290 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityZone.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityZone.java @@ -75,6 +75,17 @@ public abstract class AbilityZone extends Ability { return false; } + @Override + public boolean allowFreeOnCast() { + return true; + } + + @Override + public void detach() { + activeEntities.clear(); + preCalculatedPositions.clear(); + } + @Override public boolean isTargetingModeLocked() { return true; @@ -130,6 +141,12 @@ public abstract class AbilityZone extends Ability { @Override public void onActiveTick(EntityLivingBase caster, EntityLivingBase target, int tick) { + // Free on Cast: complete immediately — zones live independently + if (isFreeOnCast()) { + signalCompletion(); + return; + } + Iterator it = activeEntities.iterator(); while (it.hasNext()) { EntityAbilityZone entity = it.next(); diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityEnergyBarrier.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityEnergyBarrier.java index 73f34d059..3366290e9 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityEnergyBarrier.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityEnergyBarrier.java @@ -63,6 +63,16 @@ public abstract class AbilityEnergyBarrier extends AbilityEnergy { return false; // Barriers are defensive } + @Override + public boolean allowFreeOnCast() { + return true; + } + + @Override + public void detach() { + barrierEntity = null; + } + @Override public void onWindUpTick(EntityLivingBase caster, EntityLivingBase target, int tick) { if (caster.worldObj.isRemote) return; @@ -97,6 +107,12 @@ public abstract class AbilityEnergyBarrier extends AbilityEnergy { return; } + // Free on Cast: complete immediately after barrier becomes active + if (isFreeOnCast()) { + signalCompletion(); + return; + } + // Duration tracked by entity itself, but check here too as a failsafe if (barrierData.useDuration && tick >= barrierData.durationTicks) { cleanup(); diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityEnergyProjectile.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityEnergyProjectile.java index da3e95c81..50b68554e 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityEnergyProjectile.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilityEnergyProjectile.java @@ -244,6 +244,16 @@ public abstract class AbilityEnergyProjectile return true; } + @Override + public boolean allowFreeOnCast() { + return true; + } + + @Override + public void detach() { + entities = null; + } + @Override public boolean isReadyForBurstCompletion(int activeTick) { return fireDelay <= 0 || activeTick >= fireDelay * (projectileCount - 1); @@ -338,6 +348,15 @@ public abstract class AbilityEnergyProjectile } } + // Free on Cast: complete once all projectiles have been fired + if (isFreeOnCast()) { + int lastFireTick = fireDelay > 0 ? fireDelay * (projectileCount - 1) : 0; + if (tick >= lastFireTick) { + signalCompletion(); + return; + } + } + // Check if all entities are dead or unreachable boolean allDead = true; for (E entity : entities) { diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilitySweeper.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilitySweeper.java index a1340ae39..2cc64bde4 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilitySweeper.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/energy/AbilitySweeper.java @@ -62,6 +62,21 @@ public class AbilitySweeper extends AbilityEnergy implements IAbilitySweeper { return false; } + @Override + public boolean isConcurrentCapable() { + return true; + } + + @Override + public boolean allowFreeOnCast() { + return true; + } + + @Override + public void detach() { + activeEntity = null; + } + @Override public boolean isTargetingModeLocked() { return true; @@ -97,6 +112,12 @@ public class AbilitySweeper extends AbilityEnergy implements IAbilitySweeper { if (activeEntity == null || activeEntity.isDead) { activeEntity = null; signalCompletion(); + return; + } + + // Free on Cast: complete immediately — sweeper lives independently + if (isFreeOnCast()) { + signalCompletion(); } } diff --git a/src/main/java/kamkeel/npcs/entity/EntityAbilityZone.java b/src/main/java/kamkeel/npcs/entity/EntityAbilityZone.java index a5225d620..129b87495 100644 --- a/src/main/java/kamkeel/npcs/entity/EntityAbilityZone.java +++ b/src/main/java/kamkeel/npcs/entity/EntityAbilityZone.java @@ -772,7 +772,11 @@ public class EntityAbilityZone extends Entity implements IEntityAdditionalSpawnD effectList.appendTag(effect.writeNBT()); } effectsNbt.setTag("Effects", effectList); - ByteBufUtils.writeNBT(buffer, effectsNbt); + try { + ByteBufUtils.writeNBT(buffer, effectsNbt); + } catch (java.io.IOException e) { + noppes.npcs.LogWriter.error("Error writing zone effects spawn data", e); + } } @Override @@ -818,7 +822,12 @@ public class EntityAbilityZone extends Entity implements IEntityAdditionalSpawnD this.visible = buffer.readBoolean(); // Effects - NBTTagCompound effectsNbt = ByteBufUtils.readNBT(buffer); + NBTTagCompound effectsNbt = null; + try { + effectsNbt = ByteBufUtils.readNBT(buffer); + } catch (java.io.IOException e) { + noppes.npcs.LogWriter.error("Error reading zone effects spawn data", e); + } effects.clear(); if (effectsNbt != null && effectsNbt.hasKey("Effects")) { NBTTagList effectList = effectsNbt.getTagList("Effects", 10); diff --git a/src/main/java/kamkeel/npcs/entity/EntityEnergyPanel.java b/src/main/java/kamkeel/npcs/entity/EntityEnergyPanel.java index 1ef117d94..1afc1f2bc 100644 --- a/src/main/java/kamkeel/npcs/entity/EntityEnergyPanel.java +++ b/src/main/java/kamkeel/npcs/entity/EntityEnergyPanel.java @@ -36,6 +36,7 @@ public class EntityEnergyPanel extends EntityEnergyBarrier { protected EnergyPanelData panelData = new EnergyPanelData(); protected PanelMode mode = PanelMode.PLACED; protected float panelYaw = 0.0f; // Rotation of the panel face (degrees) + protected float prevPanelYaw = 0.0f; // Previous tick yaw for smooth interpolation // ==================== CHARGING (panel-specific targets) ==================== protected float targetPanelWidth; @@ -54,6 +55,7 @@ public class EntityEnergyPanel extends EntityEnergyBarrier { this.setPosition(x, y + panel.heightOffset, z); this.ownerEntityId = owner.getEntityId(); this.panelYaw = yaw; + this.prevPanelYaw = yaw; this.mode = mode; this.displayData = display; this.lightningData = lightning; @@ -99,6 +101,7 @@ public class EntityEnergyPanel extends EntityEnergyBarrier { this.prevPosX = this.posX; this.prevPosY = this.posY; this.prevPosZ = this.posZ; + this.prevPanelYaw = this.panelYaw; super.onUpdate(); @@ -548,6 +551,10 @@ public class EntityEnergyPanel extends EntityEnergyBarrier { return panelYaw; } + public float getPrevPanelYaw() { + return prevPanelYaw; + } + public EnergyPanelData getPanelData() { return panelData; } diff --git a/src/main/java/kamkeel/npcs/network/packets/player/SpecialKeyStatePacket.java b/src/main/java/kamkeel/npcs/network/packets/player/SpecialKeyStatePacket.java index 212e816b4..3592e1a9a 100644 --- a/src/main/java/kamkeel/npcs/network/packets/player/SpecialKeyStatePacket.java +++ b/src/main/java/kamkeel/npcs/network/packets/player/SpecialKeyStatePacket.java @@ -57,16 +57,19 @@ public final class SpecialKeyStatePacket extends AbstractPacket { boolean wasDown = data.isSpecialKeyDown(); data.setSpecialKeyDown(keyDown); - // Trigger ability activation on key press (rising edge) + // Trigger ability activation or cancel on key press (rising edge) if (keyDown && !wasDown) { - // Don't activate if player has a GUI open (not the default inventory) - // Don't activate if player is riding an entity (mount) if (player.ridingEntity != null) { return; } - data.abilityData.activateAbility(player); + // If currently executing an ability, try to cancel via double-press + if (data.abilityData.isExecutingAbility() || data.abilityData.isExecutingChain()) { + data.abilityData.tryCancelAbility(); + } else { + data.abilityData.activateAbility(player); + } } } } diff --git a/src/main/java/noppes/npcs/AbstractDataAbilities.java b/src/main/java/noppes/npcs/AbstractDataAbilities.java index b78ca37e7..b63481c21 100644 --- a/src/main/java/noppes/npcs/AbstractDataAbilities.java +++ b/src/main/java/noppes/npcs/AbstractDataAbilities.java @@ -1034,6 +1034,62 @@ public abstract class AbstractDataAbilities { protected void onInterruptComplete() { } + /** + * Cancel the currently executing ability (voluntary player action). + * Unlike interrupt, this always goes directly to IDLE (never DAZED), + * immediately rolls cooldown, and clears state. + * Also handles cancellation during chain delay (between chain entries). + */ + public void cancelCurrentAbility() { + boolean hasExecutingAbility = currentAbility != null && currentAbility.isExecuting(); + boolean isInChainDelay = currentChain != null && chainDelayRemaining > 0; + + if (!hasExecutingAbility && !isInChainDelay) return; + + if (hasExecutingAbility) { + AbilityPhase phase = currentAbility.getPhase(); + // Cannot cancel during DAZED (already interrupted) + if (phase == AbilityPhase.DAZED) return; + // Only allow cancel during active phases + if (phase != AbilityPhase.WINDUP && phase != AbilityPhase.ACTIVE + && phase != AbilityPhase.BURST_DELAY) return; + + // Remove telegraph + removeTelegraph(currentAbility); + + // Fire extender complete hook (cancelled = interrupted) + AbilityController.Instance.fireOnAbilityComplete(currentAbility, getEntity(), getTarget(), true); + + // Fire interrupt event (source=null, damage=0 for voluntary cancel) + fireInterruptEvent(currentAbility, getTarget(), null, 0); + + // Cancel the ability (cleanup + go directly to IDLE, no DAZED) + currentAbility.onInterrupt(getEntity(), null, 0); + currentAbility.cancel(); + + // Release locks and stop animation + stopAbilityAnimation(); + releaseRotationControl(); + releaseLockedPosition(); + } + + // Roll cooldown + if (currentChain != null) { + rollChainCooldown(currentChain); + currentChain = null; + chainEntryIndex = -1; + chainDelayRemaining = -1; + } else if (currentAbility != null) { + rollCooldown(currentAbility); + } + + // Interrupt all concurrent slots + interruptConcurrentSlots(); + + // Clear state via the normal completion path + onAbilityComplete(); + } + // ═══════════════════════════════════════════════════════════════════ // CHAINED ABILITY EXECUTION // ═══════════════════════════════════════════════════════════════════ diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java index b8958d24a..79890baeb 100644 --- a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java +++ b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java @@ -91,6 +91,16 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA */ private transient byte lastSyncedFlags = 0; + /** + * World time when the last ability was activated (for double-press cancel detection). + */ + private transient long lastAbilityActivationTime = -1; + + /** + * Ticks after activation during which a second key press will cancel the ability. + */ + private static final int CANCEL_WINDOW_TICKS = 10; + // ═══════════════════════════════════════════════════════════════════ // CONSTRUCTOR // ═══════════════════════════════════════════════════════════════════ @@ -258,6 +268,7 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA currentAbility = null; currentAbilityKey = null; currentTarget = null; + lastAbilityActivationTime = -1; syncAbilityStateClear(playerData.player); } @@ -273,6 +284,7 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA currentAbility = null; currentAbilityKey = null; currentTarget = null; + lastAbilityActivationTime = -1; syncAbilityStateClear(playerData.player); } @@ -378,6 +390,7 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA currentAbilityKey = key; currentTarget = null; + lastAbilityActivationTime = getWorldTime(); return startChain((ChainedAbility) action, null); } @@ -413,6 +426,7 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA currentAbility = ability; currentAbilityKey = key; currentTarget = null; // Players don't have auto-targets + lastAbilityActivationTime = getWorldTime(); ability.start(null); if (ability.getPhase() == AbilityPhase.ACTIVE) { @@ -662,6 +676,38 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA interruptCurrentAbility(null, 0); } + /** + * Try to cancel the current ability via double-press. + * Only succeeds if within the cancel window and not in DAZED phase. + * Also handles cancellation during chain delay (between chain entries). + * + * @return true if the ability was cancelled + */ + public boolean tryCancelAbility() { + boolean hasExecutingAbility = currentAbility != null && currentAbility.isExecuting(); + boolean isInChainDelay = currentChain != null && chainDelayRemaining > 0; + + if (!hasExecutingAbility && !isInChainDelay) return false; + + // Don't allow cancel during DAZED + if (hasExecutingAbility) { + AbilityPhase phase = currentAbility.getPhase(); + if (phase == AbilityPhase.DAZED) return false; + if (phase != AbilityPhase.WINDUP && phase != AbilityPhase.ACTIVE + && phase != AbilityPhase.BURST_DELAY) return false; + } + + long worldTime = getWorldTime(); + if (lastAbilityActivationTime < 0 || (worldTime - lastAbilityActivationTime) > CANCEL_WINDOW_TICKS) { + // Outside cancel window — update time so a quick follow-up press can cancel + lastAbilityActivationTime = worldTime; + return false; + } + + cancelCurrentAbility(); + return true; + } + // ═══════════════════════════════════════════════════════════════════ // ENTITY RECONSTRUCTION RESET // ═══════════════════════════════════════════════════════════════════ @@ -701,6 +747,7 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA currentAbility = null; currentAbilityKey = null; currentTarget = null; + lastAbilityActivationTime = -1; if (clearCooldowns) { cooldownEndTime = 0; diff --git a/src/main/resources/assets/customnpcs/lang/en_US.lang b/src/main/resources/assets/customnpcs/lang/en_US.lang index 8e2aa437c..dbe0442cd 100644 --- a/src/main/resources/assets/customnpcs/lang/en_US.lang +++ b/src/main/resources/assets/customnpcs/lang/en_US.lang @@ -1674,6 +1674,8 @@ ability.weight=Weight ability.windUpTicks=Wind Up ability.cooldownTicks=Cooldown ability.perAbilityCooldown=Per-Ability +ability.freeOnCast=Free on Cast +ability.hover.freeOnCast=Ability enters cooldown immediately after entities are spawned, allowing the caster to use other abilities while summoned entities remain active ability.lockMovement=Lock Movement ability.rotationMode=Rotation ability.rotationPhase=Phase