From e54a9a83bb2bd6f84b636f6e11d65b33a34b0c32 Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 19 Feb 2026 12:55:37 -0500 Subject: [PATCH] Create IAbilityEffect + Concurrent Ability Support for Chained Abilities --- src/api | 2 +- .../npcs/controllers/AbilityController.java | 39 +- .../controllers/data/ability/Ability.java | 12 +- .../data/ability/AbilityCustomEffect.java | 94 ++++ .../ability/AbilityEffectActionEntry.java | 86 +++ .../data/ability/AbilityTargetHelper.java | 103 ++++ .../data/ability/ChainedAbilityEntry.java | 17 + .../data/ability/ConcurrentSlot.java | 109 ++++ .../data/ability/IEffectAction.java | 42 ++ .../data/ability/TargetFilter.java | 32 ++ .../data/ability/gui/AbilityFieldBuilder.java | 343 ++++++++++-- .../data/ability/gui/AbilityFieldDefs.java | 15 + .../data/ability/type/AbilityCharge.java | 3 + .../data/ability/type/AbilityCutter.java | 3 + .../data/ability/type/AbilityEffect.java | 502 ++++++++++++++++++ .../data/ability/type/AbilityHeal.java | 316 ----------- .../data/ability/type/AbilityHeavyHit.java | 3 + .../data/ability/type/AbilityShockwave.java | 3 + .../data/ability/type/AbilitySlam.java | 8 +- .../data/ability/type/AbilityVortex.java | 3 + .../npcs/entity/EntityAbilityZone.java | 5 + src/main/java/kamkeel/npcs/util/Register.java | 34 ++ .../noppes/npcs/AbstractDataAbilities.java | 96 +++- src/main/java/noppes/npcs/DataAbilities.java | 4 +- .../client/gui/advanced/GuiNPCAbilities.java | 20 +- .../gui/advanced/SubGuiAbilityConfig.java | 16 +- .../gui/advanced/SubGuiAbilityTypeSelect.java | 11 +- .../advanced/SubGuiChainedAbilityConfig.java | 37 +- .../advanced/SubGuiCustomEffectSelect.java | 149 ++++++ .../npcs/client/gui/builder/FieldType.java | 2 + .../controllers/data/PlayerAbilityData.java | 3 +- .../assets/customnpcs/lang/en_US.lang | 24 +- 32 files changed, 1754 insertions(+), 382 deletions(-) create mode 100644 src/main/java/kamkeel/npcs/controllers/data/ability/AbilityCustomEffect.java create mode 100644 src/main/java/kamkeel/npcs/controllers/data/ability/AbilityEffectActionEntry.java create mode 100644 src/main/java/kamkeel/npcs/controllers/data/ability/AbilityTargetHelper.java create mode 100644 src/main/java/kamkeel/npcs/controllers/data/ability/ConcurrentSlot.java create mode 100644 src/main/java/kamkeel/npcs/controllers/data/ability/IEffectAction.java create mode 100644 src/main/java/kamkeel/npcs/controllers/data/ability/TargetFilter.java create mode 100644 src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityEffect.java delete mode 100644 src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityHeal.java create mode 100644 src/main/java/noppes/npcs/client/gui/advanced/SubGuiCustomEffectSelect.java diff --git a/src/api b/src/api index 2c2f0c0d8..a27e11595 160000 --- a/src/api +++ b/src/api @@ -1 +1 @@ -Subproject commit 2c2f0c0d8f21f0ab3a3bc6bea168b3d3b85aa806 +Subproject commit a27e1159508c799c1b7b612ee09574f207bd64e0 diff --git a/src/main/java/kamkeel/npcs/controllers/AbilityController.java b/src/main/java/kamkeel/npcs/controllers/AbilityController.java index 9c73edc7c..3e48b0097 100644 --- a/src/main/java/kamkeel/npcs/controllers/AbilityController.java +++ b/src/main/java/kamkeel/npcs/controllers/AbilityController.java @@ -7,6 +7,7 @@ import kamkeel.npcs.controllers.data.ability.ChainedAbility; import kamkeel.npcs.controllers.data.ability.IAbilityAction; import kamkeel.npcs.controllers.data.ability.IAbilityExtender; import kamkeel.npcs.controllers.data.ability.IAbilityFieldProvider; +import kamkeel.npcs.controllers.data.ability.IEffectAction; import kamkeel.npcs.controllers.data.ability.IChainedAbilityFieldProvider; import kamkeel.npcs.controllers.data.ability.UserType; import kamkeel.npcs.controllers.data.ability.type.AbilityCharge; @@ -14,7 +15,7 @@ import kamkeel.npcs.controllers.data.ability.type.AbilityCutter; import kamkeel.npcs.controllers.data.ability.type.AbilityDash; import kamkeel.npcs.controllers.data.ability.type.AbilityGuard; import kamkeel.npcs.controllers.data.ability.type.AbilityHazard; -import kamkeel.npcs.controllers.data.ability.type.AbilityHeal; +import kamkeel.npcs.controllers.data.ability.type.AbilityEffect; import kamkeel.npcs.controllers.data.ability.type.AbilityHeavyHit; import kamkeel.npcs.controllers.data.ability.type.AbilityProjectile; import kamkeel.npcs.controllers.data.ability.type.AbilityShockwave; @@ -79,6 +80,9 @@ public class AbilityController implements IAbilityHandler { private final Map chainedAbilitiesById = new LinkedHashMap<>(); // UUID → ChainedAbility private int chainedAbilityRevision = 0; + // ── Effect Action Registry ────────────────────────────────────────────── + private final Map effectActions = new LinkedHashMap<>(); + // ── Derived State ──────────────────────────────────────────────────────── private final Set builtInTypeIds = new HashSet<>(); @@ -143,7 +147,7 @@ public class AbilityController implements IAbilityHandler { registerType("cnpc:shockwave", AbilityShockwave::new); registerType("cnpc:guard", AbilityGuard::new); - registerType("cnpc:heal", AbilityHeal::new); + registerType("cnpc:effect", AbilityEffect::new); registerType("cnpc:hazard", AbilityHazard::new); registerType("cnpc:trap", AbilityTrap::new); @@ -926,6 +930,37 @@ public class AbilityController implements IAbilityHandler { return builtInTypeIds.contains(typeId); } + public boolean isConcurrentCapableType(String typeId) { + Supplier factory = abilityTypes.get(typeId); + if (factory == null) return false; + return factory.get().isConcurrentCapable(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // EFFECT ACTION REGISTRY + // ═══════════════════════════════════════════════════════════════════════════ + + public void registerEffectAction(IEffectAction action) { + if (action == null || action.getId() == null) return; + effectActions.put(action.getId(), action); + } + + public IEffectAction getEffectAction(String id) { + return effectActions.get(id); + } + + public String[] getEffectActionIds() { + return effectActions.keySet().toArray(new String[0]); + } + + public java.util.Collection getEffectActions() { + return effectActions.values(); + } + + public boolean hasEffectActions() { + return !effectActions.isEmpty(); + } + @Override public boolean hasType(String typeId) { return abilityTypes.containsKey(typeId); 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 9f332e12d..89e059967 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java @@ -236,6 +236,15 @@ public abstract class Ability implements IAbility, IAbilityAction { return false; } + /** + * Whether this ability can run concurrently alongside the primary ability in a chain. + * Override to return true for passive/effect abilities that don't need exclusive animations. + * Concurrent abilities skip animations entirely and run their tick/execute logic independently. + */ + public boolean isConcurrentCapable() { + return false; + } + // ═══════════════════════════════════════════════════════════════════ // TOGGLE CALLBACKS (optional overrides for toggleable abilities) // ═══════════════════════════════════════════════════════════════════ @@ -569,8 +578,7 @@ public abstract class Ability implements IAbility, IAbilityAction { defs.add(FieldDef.stringField("gui.displayName", this::getRawDisplayName, this::setDisplayName) .tab("General")); defs.add(FieldDef.labelField("ability.validFor", () -> - StatCollector.translateToLocal("ability.validFor") + ": \u00A7e" - + StatCollector.translateToLocal("ability.userType." + getAllowedBy().name())) + "\u00A7e" + StatCollector.translateToLocal("ability.userType." + getAllowedBy().name())) .tab("General")); defs.add(FieldDef.row( FieldDef.intField("ability.weight", this::getWeight, this::setWeight).range(1, 1000), diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityCustomEffect.java b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityCustomEffect.java new file mode 100644 index 000000000..144972509 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityCustomEffect.java @@ -0,0 +1,94 @@ +package kamkeel.npcs.controllers.data.ability; + +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.controllers.CustomEffectController; + +/** + * Represents a custom effect reference that can be applied by abilities. + * Wraps the CustomEffectController system (player-only effects with script callbacks). + */ +public class AbilityCustomEffect { + + private int effectId = -1; + private int durationTicks = 60; + private byte level = 0; + + public AbilityCustomEffect() { + } + + public AbilityCustomEffect(int effectId, int durationTicks, byte level) { + this.effectId = effectId; + this.durationTicks = Math.max(1, durationTicks); + this.level = (byte) Math.max(0, Math.min(10, level)); + } + + public AbilityCustomEffect copy() { + return new AbilityCustomEffect(effectId, durationTicks, level); + } + + /** + * Applies this custom effect to the given entity. + * Only works on players (CustomEffectController is player-only). + */ + public void apply(EntityLivingBase entity) { + if (entity == null || effectId <= 0) return; + if (entity instanceof EntityPlayer) { + CustomEffectController.getInstance().applyEffect( + (EntityPlayer) entity, effectId, durationTicks, level); + } + } + + public boolean isValid() { + return effectId > 0; + } + + // ── NBT ── + + public NBTTagCompound writeNBT() { + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setInteger("effectId", effectId); + nbt.setInteger("duration", durationTicks); + nbt.setByte("level", level); + return nbt; + } + + public void readNBT(NBTTagCompound nbt) { + this.effectId = nbt.getInteger("effectId"); + this.durationTicks = nbt.getInteger("duration"); + this.level = nbt.getByte("level"); + } + + public static AbilityCustomEffect fromNBT(NBTTagCompound nbt) { + AbilityCustomEffect e = new AbilityCustomEffect(); + e.readNBT(nbt); + return e; + } + + // ── Getters/Setters ── + + public int getEffectId() { + return effectId; + } + + public void setEffectId(int effectId) { + this.effectId = effectId; + } + + public int getDurationTicks() { + return durationTicks; + } + + public void setDurationTicks(int durationTicks) { + this.durationTicks = Math.max(1, durationTicks); + } + + public byte getLevel() { + return level; + } + + public void setLevel(byte level) { + this.level = (byte) Math.max(0, Math.min(10, level)); + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityEffectActionEntry.java b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityEffectActionEntry.java new file mode 100644 index 000000000..245d153b2 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityEffectActionEntry.java @@ -0,0 +1,86 @@ +package kamkeel.npcs.controllers.data.ability; + +import kamkeel.npcs.controllers.AbilityController; +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.nbt.NBTTagCompound; + +/** + * A configured instance of an {@link IEffectAction} stored on an ability. + * Holds the action ID and its configuration NBT. + */ +public class AbilityEffectActionEntry { + + private String actionId = ""; + private NBTTagCompound config = new NBTTagCompound(); + + public AbilityEffectActionEntry() { + } + + public AbilityEffectActionEntry(String actionId) { + this.actionId = actionId != null ? actionId : ""; + IEffectAction action = AbilityController.Instance.getEffectAction(this.actionId); + if (action != null) { + this.config = action.createDefaultConfig(); + } + } + + public AbilityEffectActionEntry copy() { + AbilityEffectActionEntry copy = new AbilityEffectActionEntry(); + copy.actionId = this.actionId; + copy.config = (NBTTagCompound) this.config.copy(); + return copy; + } + + /** + * Apply this action to a target entity. + */ + public void apply(EntityLivingBase caster, EntityLivingBase target) { + if (actionId.isEmpty()) return; + IEffectAction action = AbilityController.Instance.getEffectAction(actionId); + if (action != null) { + action.apply(caster, target, config); + } + } + + public boolean isValid() { + return !actionId.isEmpty() && AbilityController.Instance.getEffectAction(actionId) != null; + } + + // ── NBT ── + + public NBTTagCompound writeNBT() { + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setString("actionId", actionId); + nbt.setTag("config", config); + return nbt; + } + + public void readNBT(NBTTagCompound nbt) { + this.actionId = nbt.getString("actionId"); + this.config = nbt.hasKey("config") ? nbt.getCompoundTag("config") : new NBTTagCompound(); + } + + public static AbilityEffectActionEntry fromNBT(NBTTagCompound nbt) { + AbilityEffectActionEntry entry = new AbilityEffectActionEntry(); + entry.readNBT(nbt); + return entry; + } + + // ── Getters/Setters ── + + public String getActionId() { + return actionId; + } + + public void setActionId(String actionId) { + this.actionId = actionId != null ? actionId : ""; + } + + public NBTTagCompound getConfig() { + return config; + } + + public void setConfig(NBTTagCompound config) { + this.config = config != null ? config : new NBTTagCompound(); + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityTargetHelper.java b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityTargetHelper.java new file mode 100644 index 000000000..b45bd2b02 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityTargetHelper.java @@ -0,0 +1,103 @@ +package kamkeel.npcs.controllers.data.ability; + +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.entity.player.EntityPlayer; +import noppes.npcs.controllers.PartyController; +import noppes.npcs.controllers.data.Party; +import noppes.npcs.controllers.data.PlayerData; +import noppes.npcs.entity.EntityNPCInterface; + +/** + * Utility class for determining friend/foe relationships between entities. + * Extracted from EntityEnergyProjectile.shouldIgnoreEntity() for reuse + * across all AoE abilities and AbilityEffect targeting. + */ +public class AbilityTargetHelper { + + /** + * Determines if target is an ally of caster. + *
    + *
  • NPC vs NPC: same faction ID = ally
  • + *
  • NPC vs Player: faction friendly to player = ally
  • + *
  • Player vs NPC: NPC's faction friendly to player = ally
  • + *
  • Player vs Player: same party (if exists) = ally
  • + *
  • Passive NPCs are always considered allies (never enemies)
  • + *
+ */ + public static boolean isAlly(EntityLivingBase caster, Entity target) { + if (target == caster) return true; + + // NPC target checks + if (target instanceof EntityNPCInterface) { + EntityNPCInterface targetNpc = (EntityNPCInterface) target; + + // Passive NPCs are always considered allies + if (targetNpc.faction.isPassive) return true; + + // NPC caster: same faction = ally + if (caster instanceof EntityNPCInterface) { + EntityNPCInterface casterNpc = (EntityNPCInterface) caster; + if (casterNpc.faction.id == targetNpc.faction.id) return true; + } + + // Player caster: friendly faction = ally + if (caster instanceof EntityPlayer) { + if (targetNpc.faction.isFriendlyToPlayer((EntityPlayer) caster)) return true; + } + + return false; + } + + // Player target checks + if (target instanceof EntityPlayer && caster instanceof EntityNPCInterface) { + // NPC caster vs Player target: faction friendly to player = ally + EntityNPCInterface casterNpc = (EntityNPCInterface) caster; + return casterNpc.faction.isFriendlyToPlayer((EntityPlayer) target); + } + + if (target instanceof EntityPlayer && caster instanceof EntityPlayer) { + // Player vs Player: same party = ally + EntityPlayer casterPlayer = (EntityPlayer) caster; + EntityPlayer targetPlayer = (EntityPlayer) target; + PlayerData casterData = PlayerData.get(casterPlayer); + PlayerData targetData = PlayerData.get(targetPlayer); + if (casterData.partyUUID != null && casterData.partyUUID.equals(targetData.partyUUID)) { + Party party = PartyController.Instance().getParty(casterData.partyUUID); + if (party != null && !party.friendlyFire()) return true; + } + return false; + } + + // Unknown entity types: not an ally + return false; + } + + /** + * Determines if a target should be affected by an ability, given the + * filter mode and includeSelf setting. + * + * @param caster The entity using the ability + * @param target The potential target entity + * @param filter ALLIES, ENEMIES, or ALL + * @param includeSelf Whether to include the caster themselves + * @return true if the target should be affected + */ + public static boolean shouldAffect(EntityLivingBase caster, Entity target, + TargetFilter filter, boolean includeSelf) { + if (target == caster) return includeSelf; + if (!(target instanceof EntityLivingBase)) return false; + if (!target.isEntityAlive()) return false; + + switch (filter) { + case ALLIES: + return isAlly(caster, target); + case ENEMIES: + return !isAlly(caster, target); + case ALL: + return true; + default: + return false; + } + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityEntry.java b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityEntry.java index be8528ed0..a5bb7571b 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityEntry.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityEntry.java @@ -31,6 +31,12 @@ public class ChainedAbilityEntry { */ private int delayTicks = 0; + /** + * Whether this entry should execute concurrently (alongside the previous ability) + * rather than sequentially. Only takes effect if the resolved ability isConcurrentCapable(). + */ + private boolean concurrentEnabled = true; + public ChainedAbilityEntry() { } @@ -104,6 +110,14 @@ public class ChainedAbilityEntry { this.delayTicks = Math.max(0, delayTicks); } + public boolean isConcurrentEnabled() { + return concurrentEnabled; + } + + public void setConcurrentEnabled(boolean concurrentEnabled) { + this.concurrentEnabled = concurrentEnabled; + } + // ═══════════════════════════════════════════════════════════════════ // RESOLUTION // ═══════════════════════════════════════════════════════════════════ @@ -157,6 +171,7 @@ public class ChainedAbilityEntry { copy.entryType = this.entryType; copy.abilityReference = this.abilityReference; copy.delayTicks = this.delayTicks; + copy.concurrentEnabled = this.concurrentEnabled; if (this.inlineAbility != null) { copy.inlineAbility = AbilityController.Instance.fromNBT(this.inlineAbility.writeNBT()); } @@ -170,6 +185,7 @@ public class ChainedAbilityEntry { public NBTTagCompound writeNBT() { NBTTagCompound nbt = new NBTTagCompound(); nbt.setInteger("Delay", delayTicks); + nbt.setBoolean("Concurrent", concurrentEnabled); if (entryType == EntryType.INLINE && inlineAbility != null) { nbt.setTag("InlineAbility", inlineAbility.writeNBT()); @@ -184,6 +200,7 @@ public class ChainedAbilityEntry { ChainedAbilityEntry entry = new ChainedAbilityEntry(); entry.delayTicks = Math.max(0, nbt.getInteger("Delay")); + entry.concurrentEnabled = nbt.hasKey("Concurrent") ? nbt.getBoolean("Concurrent") : true; // Inline entry (new format) if (nbt.hasKey("InlineAbility")) { diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/ConcurrentSlot.java b/src/main/java/kamkeel/npcs/controllers/data/ability/ConcurrentSlot.java new file mode 100644 index 000000000..cdfead9c0 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/ConcurrentSlot.java @@ -0,0 +1,109 @@ +package kamkeel.npcs.controllers.data.ability; + +import net.minecraft.entity.EntityLivingBase; + +/** + * Wraps a concurrent ability execution with its own independent lifecycle. + * Concurrent abilities skip animations, sounds, and movement/rotation locks. + * They run their execute/tick logic in parallel with the primary ability. + *

+ * Used by the chain system when an entry is concurrent-capable and concurrent-enabled. + */ +public class ConcurrentSlot { + + private final Ability ability; + private boolean started = false; + private boolean completed = false; + + public ConcurrentSlot(Ability ability) { + this.ability = ability; + } + + public Ability getAbility() { + return ability; + } + + public boolean isCompleted() { + return completed; + } + + /** + * Start the concurrent ability. Skips windup — goes straight to ACTIVE. + * Called once when the slot is activated. + */ + public void start(EntityLivingBase caster, EntityLivingBase target) { + // Skip windup for concurrent execution — fire immediately + ability.setWindUpTicks(0); + ability.start(target); + started = true; + + // Ability should now be in ACTIVE phase (0 windup → immediate) + if (ability.getPhase() == AbilityPhase.ACTIVE) { + ability.onExecute(caster, target); + } + + // Check if ability completed during onExecute (instant abilities like signalCompletion) + if (ability.getPhase() == AbilityPhase.IDLE) { + completed = true; + } + } + + /** + * Tick the concurrent ability. Called every game tick from AbstractDataAbilities. + * Handles phase transitions including burst delay → re-execute cycles. + */ + public void tick(EntityLivingBase caster, EntityLivingBase target) { + if (completed || !started) return; + + AbilityPhase oldPhase = ability.getPhase(); + boolean phaseChanged = ability.tick(); + AbilityPhase newPhase = ability.getPhase(); + + switch (newPhase) { + case ACTIVE: + if (phaseChanged && oldPhase == AbilityPhase.BURST_DELAY) { + // Re-entering ACTIVE after burst delay → re-execute (burst re-fire) + ability.onExecute(caster, target); + if (ability.getPhase() == AbilityPhase.IDLE) { + completed = true; + return; + } + } + // Normal active tick + ability.onActiveTick(caster, target, ability.getCurrentTick()); + if (ability.getPhase() == AbilityPhase.IDLE) { + completed = true; + } + break; + + case BURST_DELAY: + // Waiting between bursts — nothing to do, tick() handles countdown + break; + + case WINDUP: + // Burst replay with windup — for concurrent, skip it by advancing + // (windUpTicks was set to 0 at start, but burst replay may re-enter WINDUP) + // The next tick() call will transition to ACTIVE + break; + + case IDLE: + completed = true; + break; + + case DAZED: + // Concurrent abilities don't get dazed — treat as completed + completed = true; + break; + } + } + + /** + * Force cleanup on interrupt. Called when the NPC is interrupted or combat ends. + */ + public void interrupt() { + if (!completed) { + ability.interrupt(); + completed = true; + } + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/IEffectAction.java b/src/main/java/kamkeel/npcs/controllers/data/ability/IEffectAction.java new file mode 100644 index 000000000..2f0323c76 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/IEffectAction.java @@ -0,0 +1,42 @@ +package kamkeel.npcs.controllers.data.ability; + +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.nbt.NBTTagCompound; + +/** + * Interface for mod-registered effect actions that can be applied by AbilityEffect. + *

+ * Mods register implementations via {@code Register.EffectActions} or + * {@code AbilityController.Instance.registerEffectAction()}. + * Each action has a unique ID, display name, and apply logic. + * Actions can optionally provide custom configuration fields for the GUI. + */ +public interface IEffectAction { + + /** + * Unique identifier, e.g. "dbc:kiRestore", "mymod:buffSpeed". + */ + String getId(); + + /** + * Localized display name shown in the GUI selector. + */ + String getDisplayName(); + + /** + * Apply this action to a target entity. + * Called once per affected entity during ability execution. + * + * @param caster The entity that used the ability + * @param target The entity being affected + * @param config Action-specific configuration NBT (from GUI) + */ + void apply(EntityLivingBase caster, EntityLivingBase target, NBTTagCompound config); + + /** + * Create default configuration NBT for a new instance of this action. + */ + default NBTTagCompound createDefaultConfig() { + return new NBTTagCompound(); + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/TargetFilter.java b/src/main/java/kamkeel/npcs/controllers/data/ability/TargetFilter.java new file mode 100644 index 000000000..0247cc170 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/TargetFilter.java @@ -0,0 +1,32 @@ +package kamkeel.npcs.controllers.data.ability; + +/** + * Filter for determining which entities an ability should affect in AOE mode. + */ +public enum TargetFilter { + ALLIES, + ENEMIES, + ALL; + + @Override + public String toString() { + switch (this) { + case ALLIES: + return "ability.targetFilter.allies"; + case ENEMIES: + return "ability.targetFilter.enemies"; + case ALL: + return "ability.targetFilter.all"; + default: + return name(); + } + } + + public static TargetFilter fromString(String name) { + try { + return valueOf(name); + } catch (Exception e) { + return ALLIES; + } + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/gui/AbilityFieldBuilder.java b/src/main/java/kamkeel/npcs/controllers/data/ability/gui/AbilityFieldBuilder.java index 2456b161d..6cbd0b6f9 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/gui/AbilityFieldBuilder.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/gui/AbilityFieldBuilder.java @@ -2,9 +2,14 @@ package kamkeel.npcs.controllers.data.ability.gui; import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; +import kamkeel.npcs.controllers.AbilityController; +import kamkeel.npcs.controllers.data.ability.AbilityCustomEffect; +import kamkeel.npcs.controllers.data.ability.AbilityEffectActionEntry; import kamkeel.npcs.controllers.data.ability.AbilityPotionEffect; +import kamkeel.npcs.controllers.data.ability.IEffectAction; import net.minecraft.client.gui.FontRenderer; import net.minecraft.client.gui.GuiButton; +import noppes.npcs.client.gui.advanced.SubGuiCustomEffectSelect; import noppes.npcs.client.gui.builder.FieldDef; import noppes.npcs.client.gui.builder.FieldType; import noppes.npcs.client.gui.builder.GuiFieldBuilder; @@ -13,23 +18,36 @@ import noppes.npcs.client.gui.util.GuiNpcButton; import noppes.npcs.client.gui.util.GuiNpcLabel; import noppes.npcs.client.gui.util.GuiNpcTextField; import noppes.npcs.constants.EnumPotionType; +import noppes.npcs.controllers.CustomEffectController; +import noppes.npcs.controllers.data.CustomEffect; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; /** * Ability-specific extension of {@link GuiFieldBuilder} that handles - * the EFFECTS_LIST field type. + * the EFFECTS_LIST, CUSTOM_EFFECTS_LIST, and EFFECT_ACTIONS_LIST field types. */ @SideOnly(Side.CLIENT) public class AbilityFieldBuilder extends GuiFieldBuilder { - // Effects list metadata: widgetId -> [effectIndex, action] - // action: 0=type, 1=duration(text), 2=amp, 3=delete, 4=add, 5=manualId(text) + // Unified metadata: widgetId -> [index, action] + // EFFECTS_LIST actions: 0=type, 1=duration(text), 2=amp, 3=delete, 4=add, 5=manualId(text) + // CUSTOM_EFFECTS_LIST actions: 0=effectName, 1=duration(text), 2=level, 3=delete, 4=add + // EFFECT_ACTIONS_LIST actions: 0=actionSelector, 3=delete, 4=add private final Map effectWidgetMeta = new HashMap<>(); + // Cached sorted custom effect data for GUI selectors + private int[] sortedCustomEffectIds; + private String[] sortedCustomEffectNames; + + // Cached effect action data for GUI selectors + private String[] effectActionIds; + private String[] effectActionDisplayNames; + public AbilityFieldBuilder(GuiNPCInterface parent, FontRenderer fontRenderer) { super(parent, fontRenderer); } @@ -37,6 +55,10 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { @Override protected int build(List fields) { effectWidgetMeta.clear(); + sortedCustomEffectIds = null; + sortedCustomEffectNames = null; + effectActionIds = null; + effectActionDisplayNames = null; return super.build(fields); } @@ -45,12 +67,21 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { if (def.getType() == FieldType.EFFECTS_LIST) { return renderEffectsList(def, y); } + if (def.getType() == FieldType.CUSTOM_EFFECTS_LIST) { + return renderCustomEffectsList(def, y); + } + if (def.getType() == FieldType.EFFECT_ACTIONS_LIST) { + return renderEffectActionsList(def, y); + } return -1; // not handled } + // ═══════════════════════════════════════════════════════════════════ + // POTION EFFECTS LIST (existing) + // ═══════════════════════════════════════════════════════════════════ + @SuppressWarnings("unchecked") private int renderEffectsList(FieldDef def, int y) { - // Section header y += 3; sw.addLabel(new GuiNpcLabel(labelId++, def.getLabel(), colLLabel, y + 2, 0xFFFF55)); y += 15; @@ -64,7 +95,6 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { for (int e = 0; e < effects.size() && e < 5; e++) { AbilityPotionEffect effect = effects.get(e); - // Type selector button (index offset by -1 since None is excluded) int typeIdx = effect.getType().ordinal() - 1; GuiNpcButton typeBtn = new GuiNpcButton(widgetId, colLLabel, y, 100, 20, typeNames, Math.max(0, typeIdx)); sw.addButton(typeBtn); @@ -72,7 +102,6 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { effectWidgetMeta.put(widgetId, new int[]{e, 0}); widgetId++; - // Duration text field GuiNpcTextField durField = new GuiNpcTextField(widgetId, parent, fontRenderer, colLLabel + 104, y, 50, 20, String.valueOf(effect.getDurationTicks())); durField.setIntegersOnly(); @@ -83,17 +112,15 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { widgetId++; if (effect.getType() != EnumPotionType.Fire) { - // Amplifier selector (0-10) — not applicable to Fire GuiNpcButton ampBtn = new GuiNpcButton(widgetId, colLLabel + 158, y, 40, 20, ampValues, effect.getAmplifier()); sw.addButton(ampBtn); buttonFieldMap.put(widgetId, def); effectWidgetMeta.put(widgetId, new int[]{e, 2}); widgetId++; } else { - widgetId++; // keep widgetId in sync + widgetId++; } - // Delete button GuiNpcButton delBtn = new GuiNpcButton(clearId, colLLabel + 202, y, 20, 20, "X"); sw.addButton(delBtn); clearFieldMap.put(clearId, def); @@ -102,7 +129,6 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { y += rowHeight; - // Manual potion ID field (shown on next row when type is Manual) if (effect.getType() == EnumPotionType.Manual) { sw.addLabel(new GuiNpcLabel(labelId++, "effect.potionid", colLLabel, y + 5, 0xFFFFFF)); GuiNpcTextField idField = new GuiNpcTextField(widgetId, parent, fontRenderer, @@ -117,7 +143,6 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { } } - // Add button (if < 5 effects) if (effects.size() < 5) { GuiNpcButton addBtn = new GuiNpcButton(widgetId, colLLabel, y, 50, 20, "gui.add"); sw.addButton(addBtn); @@ -131,6 +156,184 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { return y; } + // ═══════════════════════════════════════════════════════════════════ + // CUSTOM EFFECTS LIST + // ═══════════════════════════════════════════════════════════════════ + + private void ensureCustomEffectCache() { + if (sortedCustomEffectIds != null) return; + + HashMap allEffects = CustomEffectController.getInstance().getCustomEffects(); + // Sort by name for consistent ordering + TreeMap nameToId = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (CustomEffect ce : allEffects.values()) { + if (ce.getName() != null && !ce.getName().isEmpty()) { + nameToId.put(ce.getName(), ce.id); + } + } + + sortedCustomEffectIds = new int[nameToId.size()]; + sortedCustomEffectNames = new String[nameToId.size()]; + int i = 0; + for (Map.Entry entry : nameToId.entrySet()) { + sortedCustomEffectIds[i] = entry.getValue(); + sortedCustomEffectNames[i] = entry.getKey(); + i++; + } + } + + private String getCustomEffectName(int effectId) { + HashMap allEffects = CustomEffectController.getInstance().getCustomEffects(); + if (allEffects != null) { + CustomEffect ce = allEffects.get(effectId); + if (ce != null && ce.getName() != null && !ce.getName().isEmpty()) { + return ce.getName(); + } + } + return "?"; + } + + @SuppressWarnings("unchecked") + private int renderCustomEffectsList(FieldDef def, int y) { + ensureCustomEffectCache(); + + y += 3; + sw.addLabel(new GuiNpcLabel(labelId++, def.getLabel(), colLLabel, y + 2, 0xFFFF55)); + y += 15; + + if (sortedCustomEffectNames.length == 0) { + sw.addLabel(new GuiNpcLabel(labelId++, "ability.noCustomEffects", colLLabel, y + 5, 0x888888)); + y += rowHeight; + return y; + } + + List effects = (List) def.getValue(); + if (effects == null) effects = new ArrayList<>(); + + String[] levelValues = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}; + + for (int e = 0; e < effects.size() && e < 5; e++) { + AbilityCustomEffect effect = effects.get(e); + + // Effect name button — opens SubGui picker + String effectName = getCustomEffectName(effect.getEffectId()); + GuiNpcButton nameBtn = new GuiNpcButton(widgetId, colLLabel, y, 100, 20, effectName); + sw.addButton(nameBtn); + buttonFieldMap.put(widgetId, def); + effectWidgetMeta.put(widgetId, new int[]{e, 0}); + widgetId++; + + // Duration text field + GuiNpcTextField durField = new GuiNpcTextField(widgetId, parent, fontRenderer, + colLLabel + 104, y, 50, 20, String.valueOf(effect.getDurationTicks())); + durField.setIntegersOnly(); + durField.setMinMaxDefault(1, 12000, 60); + sw.addTextField(durField); + textFieldMap.put(widgetId, def); + effectWidgetMeta.put(widgetId, new int[]{e, 1}); + widgetId++; + + // Level selector (0-10) + GuiNpcButton lvlBtn = new GuiNpcButton(widgetId, colLLabel + 158, y, 40, 20, levelValues, effect.getLevel()); + sw.addButton(lvlBtn); + buttonFieldMap.put(widgetId, def); + effectWidgetMeta.put(widgetId, new int[]{e, 2}); + widgetId++; + + // Delete button + GuiNpcButton delBtn = new GuiNpcButton(clearId, colLLabel + 202, y, 20, 20, "X"); + sw.addButton(delBtn); + clearFieldMap.put(clearId, def); + effectWidgetMeta.put(clearId, new int[]{e, 3}); + clearId++; + + y += rowHeight; + } + + if (effects.size() < 5) { + GuiNpcButton addBtn = new GuiNpcButton(widgetId, colLLabel, y, 50, 20, "gui.add"); + sw.addButton(addBtn); + buttonFieldMap.put(widgetId, def); + effectWidgetMeta.put(widgetId, new int[]{effects.size(), 4}); + widgetId++; + clearId++; + y += rowHeight; + } + + return y; + } + + // ═══════════════════════════════════════════════════════════════════ + // EFFECT ACTIONS LIST + // ═══════════════════════════════════════════════════════════════════ + + private void ensureEffectActionCache() { + if (effectActionIds != null) return; + + String[] ids = AbilityController.Instance.getEffectActionIds(); + effectActionIds = ids; + effectActionDisplayNames = new String[ids.length]; + for (int i = 0; i < ids.length; i++) { + IEffectAction action = AbilityController.Instance.getEffectAction(ids[i]); + effectActionDisplayNames[i] = action != null ? action.getDisplayName() : ids[i]; + } + } + + private int findEffectActionIndex(String actionId) { + ensureEffectActionCache(); + for (int i = 0; i < effectActionIds.length; i++) { + if (effectActionIds[i].equals(actionId)) return i; + } + return -1; + } + + @SuppressWarnings("unchecked") + private int renderEffectActionsList(FieldDef def, int y) { + ensureEffectActionCache(); + + if (effectActionIds.length == 0) return y; + + y += 3; + sw.addLabel(new GuiNpcLabel(labelId++, def.getLabel(), colLLabel, y + 2, 0xFFFF55)); + y += 15; + + List entries = (List) def.getValue(); + if (entries == null) entries = new ArrayList<>(); + + for (int e = 0; e < entries.size() && e < 5; e++) { + AbilityEffectActionEntry entry = entries.get(e); + + // Action selector + int selectedIdx = Math.max(0, findEffectActionIndex(entry.getActionId())); + GuiNpcButton actionBtn = new GuiNpcButton(widgetId, colLLabel, y, 180, 20, effectActionDisplayNames, selectedIdx); + sw.addButton(actionBtn); + buttonFieldMap.put(widgetId, def); + effectWidgetMeta.put(widgetId, new int[]{e, 0}); + widgetId++; + + // Delete button + GuiNpcButton delBtn = new GuiNpcButton(clearId, colLLabel + 202, y, 20, 20, "X"); + sw.addButton(delBtn); + clearFieldMap.put(clearId, def); + effectWidgetMeta.put(clearId, new int[]{e, 3}); + clearId++; + + y += rowHeight; + } + + if (entries.size() < 5) { + GuiNpcButton addBtn = new GuiNpcButton(widgetId, colLLabel, y, 50, 20, "gui.add"); + sw.addButton(addBtn); + buttonFieldMap.put(widgetId, def); + effectWidgetMeta.put(widgetId, new int[]{entries.size(), 4}); + widgetId++; + clearId++; + y += rowHeight; + } + + return y; + } + // ═══════════════════════════════════════════════════════════════════ // EVENT HANDLING OVERRIDES // ═══════════════════════════════════════════════════════════════════ @@ -138,36 +341,96 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { @Override @SuppressWarnings("unchecked") public boolean handleButtonEvent(int buttonId, GuiButton button) { - // Check effects list buttons via metadata int[] meta = effectWidgetMeta.get(buttonId); if (meta != null) { FieldDef def = buttonFieldMap.get(buttonId); if (def == null) def = clearFieldMap.get(buttonId); - if (def != null && def.getType() == FieldType.EFFECTS_LIST) { + if (def == null) return false; + + int idx = meta[0]; + int action = meta[1]; + + // ── Potion Effects ── + if (def.getType() == FieldType.EFFECTS_LIST) { List effects = (List) def.getValue(); if (effects == null) return false; - int effectIdx = meta[0]; - int action = meta[1]; - switch (action) { - case 0: // Type changed - if (effectIdx < effects.size()) { - effects.get(effectIdx).setType(EnumPotionType.fromIndexNoNone(((GuiNpcButton) button).getValue())); + case 0: + if (idx < effects.size()) + effects.get(idx).setType(EnumPotionType.fromIndexNoNone(((GuiNpcButton) button).getValue())); + return true; + case 2: + if (idx < effects.size()) + effects.get(idx).setAmplifier(((GuiNpcButton) button).getValue()); + return true; + case 3: + if (idx < effects.size()) effects.remove(idx); + return true; + case 4: + if (effects.size() < 5) effects.add(new AbilityPotionEffect(EnumPotionType.Slowness, 60, 0)); + return true; + } + } + + // ── Custom Effects ── + if (def.getType() == FieldType.CUSTOM_EFFECTS_LIST) { + List effects = (List) def.getValue(); + if (effects == null) return false; + ensureCustomEffectCache(); + switch (action) { + case 0: // Open effect picker SubGui + if (idx < effects.size()) { + int currentId = effects.get(idx).getEffectId(); + parent.setSubGuiWithResult(new SubGuiCustomEffectSelect(currentId), sub -> { + int selectedId = ((SubGuiCustomEffectSelect) sub).getSelectedEffectId(); + if (selectedId >= 0 && idx < effects.size()) { + effects.get(idx).setEffectId(selectedId); + } + }); } return true; - case 2: // Amp changed - if (effectIdx < effects.size()) { - effects.get(effectIdx).setAmplifier(((GuiNpcButton) button).getValue()); + case 2: // Level changed + if (idx < effects.size()) + effects.get(idx).setLevel((byte) ((GuiNpcButton) button).getValue()); + return true; + case 3: // Delete + if (idx < effects.size()) effects.remove(idx); + return true; + case 4: // Add + if (effects.size() < 5 && sortedCustomEffectIds.length > 0) { + parent.setSubGuiWithResult(new SubGuiCustomEffectSelect(-1), sub -> { + int selectedId = ((SubGuiCustomEffectSelect) sub).getSelectedEffectId(); + if (selectedId >= 0 && effects.size() < 5) { + effects.add(new AbilityCustomEffect(selectedId, 60, (byte) 0)); + } + }); + } + return true; + } + } + + // ── Effect Actions ── + if (def.getType() == FieldType.EFFECT_ACTIONS_LIST) { + List entries = (List) def.getValue(); + if (entries == null) return false; + ensureEffectActionCache(); + switch (action) { + case 0: // Action selector changed + if (idx < entries.size()) { + int btnVal = ((GuiNpcButton) button).getValue(); + if (btnVal >= 0 && btnVal < effectActionIds.length) { + entries.get(idx).setActionId(effectActionIds[btnVal]); + IEffectAction ea = AbilityController.Instance.getEffectAction(effectActionIds[btnVal]); + if (ea != null) entries.get(idx).setConfig(ea.createDefaultConfig()); + } } return true; case 3: // Delete - if (effectIdx < effects.size()) { - effects.remove(effectIdx); - } + if (idx < entries.size()) entries.remove(idx); return true; case 4: // Add - if (effects.size() < 5) { - effects.add(new AbilityPotionEffect(EnumPotionType.Slowness, 60, 0)); + if (entries.size() < 5 && effectActionIds.length > 0) { + entries.add(new AbilityEffectActionEntry(effectActionIds[0])); } return true; } @@ -183,14 +446,26 @@ public class AbilityFieldBuilder extends GuiFieldBuilder { int[] meta = effectWidgetMeta.get(textFieldId); if (meta != null) { FieldDef def = textFieldMap.get(textFieldId); - if (def != null && def.getType() == FieldType.EFFECTS_LIST) { + if (def == null) return false; + + int idx = meta[0]; + int action = meta[1]; + + // ── Potion Effects ── + if (def.getType() == FieldType.EFFECTS_LIST) { List effects = (List) def.getValue(); - if (effects != null && meta[0] < effects.size()) { - if (meta[1] == 1) { // duration field - effects.get(meta[0]).setDurationTicks(field.getInteger()); - } else if (meta[1] == 5) { // manual potion ID field - effects.get(meta[0]).setManualPotionId(field.getInteger()); - } + if (effects != null && idx < effects.size()) { + if (action == 1) effects.get(idx).setDurationTicks(field.getInteger()); + else if (action == 5) effects.get(idx).setManualPotionId(field.getInteger()); + } + return true; + } + + // ── Custom Effects ── + if (def.getType() == FieldType.CUSTOM_EFFECTS_LIST) { + List effects = (List) def.getValue(); + if (effects != null && idx < effects.size()) { + if (action == 1) effects.get(idx).setDurationTicks(field.getInteger()); } return true; } diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/gui/AbilityFieldDefs.java b/src/main/java/kamkeel/npcs/controllers/data/ability/gui/AbilityFieldDefs.java index be7b29aa6..d34620c75 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/gui/AbilityFieldDefs.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/gui/AbilityFieldDefs.java @@ -2,6 +2,8 @@ package kamkeel.npcs.controllers.data.ability.gui; import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; +import kamkeel.npcs.controllers.data.ability.AbilityCustomEffect; +import kamkeel.npcs.controllers.data.ability.AbilityEffectActionEntry; import kamkeel.npcs.controllers.data.ability.AbilityPotionEffect; import noppes.npcs.client.gui.builder.FieldDef; import noppes.npcs.client.gui.builder.FieldType; @@ -24,4 +26,17 @@ public class AbilityFieldDefs { v -> setter.accept((List) v)); } + @SuppressWarnings("unchecked") + public static FieldDef customEffectsListField(String label, Supplier> getter, Consumer> setter) { + return FieldDef.custom(label, FieldType.CUSTOM_EFFECTS_LIST, + () -> getter.get(), + v -> setter.accept((List) v)); + } + + @SuppressWarnings("unchecked") + public static FieldDef effectActionsListField(String label, Supplier> getter, Consumer> setter) { + return FieldDef.custom(label, FieldType.EFFECT_ACTIONS_LIST, + () -> getter.get(), + v -> setter.accept((List) v)); + } } diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityCharge.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityCharge.java index 120f6190d..d6d7044f3 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityCharge.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityCharge.java @@ -2,7 +2,9 @@ package kamkeel.npcs.controllers.data.ability.type; import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; +import kamkeel.npcs.controllers.data.ability.AbilityTargetHelper; import kamkeel.npcs.controllers.data.ability.LockMovementType; +import kamkeel.npcs.controllers.data.ability.TargetFilter; import kamkeel.npcs.controllers.data.ability.TargetingMode; import kamkeel.npcs.controllers.data.ability.gui.AbilityFieldDefs; import kamkeel.npcs.controllers.data.telegraph.Telegraph; @@ -137,6 +139,7 @@ public class AbilityCharge extends AbilityMovement implements IAbilityCharge { if (!(entity instanceof EntityLivingBase)) continue; if (entity == caster) continue; if (hitEntities.contains(entity.getEntityId())) continue; + if (!AbilityTargetHelper.shouldAffect(caster, entity, TargetFilter.ENEMIES, false)) continue; EntityLivingBase livingEntity = (EntityLivingBase) entity; diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityCutter.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityCutter.java index bac7cc234..e49e5bb55 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityCutter.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityCutter.java @@ -3,7 +3,9 @@ package kamkeel.npcs.controllers.data.ability.type; import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; import kamkeel.npcs.controllers.data.ability.Ability; +import kamkeel.npcs.controllers.data.ability.AbilityTargetHelper; import kamkeel.npcs.controllers.data.ability.LockMovementType; +import kamkeel.npcs.controllers.data.ability.TargetFilter; import kamkeel.npcs.controllers.data.ability.TargetingMode; import kamkeel.npcs.controllers.data.ability.gui.AbilityFieldDefs; import kamkeel.npcs.controllers.data.telegraph.Telegraph; @@ -247,6 +249,7 @@ public class AbilityCutter extends Ability implements IAbilityCutter { for (EntityLivingBase entity : entities) { if (entity == caster) continue; if (hitEntities.contains(entity.getEntityId())) continue; + if (!AbilityTargetHelper.shouldAffect(caster, entity, TargetFilter.ENEMIES, false)) continue; // If not piercing, stop after hitting one entity this sweep if (!piercing && !hitEntities.isEmpty()) break; diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityEffect.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityEffect.java new file mode 100644 index 000000000..c81e97ced --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityEffect.java @@ -0,0 +1,502 @@ +package kamkeel.npcs.controllers.data.ability.type; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import kamkeel.npcs.controllers.AbilityController; +import kamkeel.npcs.controllers.data.ability.Ability; +import kamkeel.npcs.controllers.data.ability.AbilityCustomEffect; +import kamkeel.npcs.controllers.data.ability.AbilityEffectActionEntry; +import kamkeel.npcs.controllers.data.ability.AbilityTargetHelper; +import kamkeel.npcs.controllers.data.ability.AbilityVariant; +import kamkeel.npcs.controllers.data.ability.LockMovementType; +import kamkeel.npcs.controllers.data.ability.TargetFilter; +import kamkeel.npcs.controllers.data.ability.TargetingMode; +import kamkeel.npcs.controllers.data.ability.gui.AbilityFieldDefs; +import kamkeel.npcs.controllers.data.telegraph.TelegraphType; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.util.AxisAlignedBB; +import net.minecraft.world.World; +import noppes.npcs.api.ability.type.IAbilityEffect; +import noppes.npcs.client.gui.builder.FieldDef; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Effect ability: Apply healing, potion effects, custom effects, and mod-registered + * effect actions to self or nearby entities. + *

+ * Supports: + *

    + *
  • SELF or AOE_SELF targeting modes
  • + *
  • AOE target filtering: ALLIES, ENEMIES, or ALL
  • + *
  • Healing (fixed amount + percentage)
  • + *
  • Potion effects via base Ability.effects list
  • + *
  • Custom effects from CustomEffectController (player-only)
  • + *
  • Mod-registered effect actions via IEffectAction registry
  • + *
  • Burst system for repeated reapplication (auras, debuffs)
  • + *
  • Concurrent execution alongside other abilities in chains
  • + *
+ */ +public class AbilityEffect extends Ability implements IAbilityEffect { + + // Type-specific parameters + private int durationTicks = 60; + private float healAmount = 10.0f; + private float healPercent = 0.0f; + private boolean includeSelf = true; + private float radius = 8.0f; + private boolean instantHeal = true; + private TargetFilter targetFilter = TargetFilter.ALLIES; + + // Custom effects (from CustomEffectController — player-only) + private List customEffects = new ArrayList<>(); + + // Mod-registered effect actions + private List effectActions = new ArrayList<>(); + + // Runtime state + private transient List affectedEntities; + + private List getAffectedEntities() { + if (affectedEntities == null) { + affectedEntities = new ArrayList<>(); + } + return affectedEntities; + } + + public AbilityEffect() { + this.typeId = "ability.cnpc.effect"; + this.name = "Effect"; + this.targetingMode = TargetingMode.SELF; + this.lockMovement = LockMovementType.WINDUP; + this.cooldownTicks = 0; + this.windUpTicks = 30; + this.telegraphType = TelegraphType.NONE; + this.showTelegraph = false; + } + + // ==================== ABILITY TYPE OVERRIDES ==================== + + @Override + public boolean hasDamage() { + return false; + } + + @Override + public boolean allowBurst() { + return true; + } + + @Override + public boolean isTargetingModeLocked() { + return false; + } + + @Override + public TargetingMode[] getAllowedTargetingModes() { + return new TargetingMode[]{TargetingMode.SELF, TargetingMode.AOE_SELF}; + } + + @Override + public boolean isConcurrentCapable() { + return true; + } + + @Override + public float getTelegraphRadius() { + return targetingMode == TargetingMode.AOE_SELF && radius > 0 ? radius : 0; + } + + // ==================== EXECUTION ==================== + + @Override + public void onExecute(EntityLivingBase caster, EntityLivingBase target) { + if (caster.worldObj.isRemote && !isPreview()) return; + + if (!isPreview()) { + getAffectedEntities().clear(); + + if (targetingMode == TargetingMode.AOE_SELF && radius > 0) { + findEntitiesInRadius(caster, caster.worldObj); + } else { + if (includeSelf) { + getAffectedEntities().add(caster); + } + } + + if (instantHeal) { + for (EntityLivingBase entity : getAffectedEntities()) { + healEntity(entity); + applyAllEffects(caster, entity); + spawnHealParticles(caster.worldObj, entity); + } + } + } + + if (instantHeal) { + signalCompletion(); + } + } + + @Override + public void onActiveTick(EntityLivingBase caster, EntityLivingBase target, int tick) { + if ((caster.worldObj.isRemote && !isPreview()) || instantHeal) return; + + if (tick % 10 == 0) { + float tickHealFixed = (healAmount / (float) durationTicks) * 10; + + for (EntityLivingBase entity : getAffectedEntities()) { + if (entity.isDead) continue; + + float totalTickHeal = tickHealFixed; + if (healPercent > 0) { + totalTickHeal += (entity.getMaxHealth() * healPercent / (float) durationTicks) * 10; + } + + if (totalTickHeal > 0) { + entity.heal(totalTickHeal); + } + + if (tick % 20 == 0) { + applyAllEffects(caster, entity); + spawnHealParticles(caster.worldObj, entity); + } + } + } + + if (tick >= durationTicks) { + signalCompletion(); + } + } + + // ==================== EFFECT APPLICATION ==================== + + /** + * Applies all configured effect types to a single entity: + * vanilla potion effects, custom effects, and mod-registered effect actions. + */ + private void applyAllEffects(EntityLivingBase caster, EntityLivingBase entity) { + applyEffects(entity); + applyCustomEffects(entity); + applyEffectActions(caster, entity); + } + + private void applyCustomEffects(EntityLivingBase entity) { + for (AbilityCustomEffect ce : customEffects) { + if (ce.isValid()) { + ce.apply(entity); + } + } + } + + private void applyEffectActions(EntityLivingBase caster, EntityLivingBase entity) { + for (AbilityEffectActionEntry ea : effectActions) { + if (ea.isValid()) { + ea.apply(caster, entity); + } + } + } + + // ==================== TARGET GATHERING ==================== + + @SuppressWarnings("unchecked") + private void findEntitiesInRadius(EntityLivingBase caster, World world) { + AxisAlignedBB aabb = AxisAlignedBB.getBoundingBox( + caster.posX - radius, caster.posY - 2, caster.posZ - radius, + caster.posX + radius, caster.posY + 3, caster.posZ + radius + ); + + List entities = world.getEntitiesWithinAABB(EntityLivingBase.class, aabb); + + for (Entity entity : entities) { + if (!(entity instanceof EntityLivingBase)) continue; + EntityLivingBase living = (EntityLivingBase) entity; + + float dist = caster.getDistanceToEntity(living); + if (dist > radius) continue; + + if (!AbilityTargetHelper.shouldAffect(caster, living, targetFilter, includeSelf)) continue; + + getAffectedEntities().add(living); + } + } + + private void healEntity(EntityLivingBase entity) { + float totalHeal = healAmount; + if (healPercent > 0) { + totalHeal += entity.getMaxHealth() * healPercent; + } + if (totalHeal > 0) { + entity.heal(totalHeal); + } + } + + private void spawnHealParticles(World world, EntityLivingBase entity) { + for (int i = 0; i < 10; i++) { + double offsetX = (world.rand.nextDouble() - 0.5) * entity.width; + double offsetY = world.rand.nextDouble() * entity.height; + double offsetZ = (world.rand.nextDouble() - 0.5) * entity.width; + world.spawnParticle("happyVillager", + entity.posX + offsetX, + entity.posY + offsetY, + entity.posZ + offsetZ, + 0, 0.1, 0); + } + } + + // ==================== LIFECYCLE ==================== + + @Override + public void resetForBurst() { + getAffectedEntities().clear(); + } + + @Override + public void cleanup() { + getAffectedEntities().clear(); + } + + // ==================== VARIANTS ==================== + + @Override + public List getVariants() { + return Arrays.asList( + new AbilityVariant("ability.variant.selfHeal", a -> { + AbilityEffect e = (AbilityEffect) a; + a.setName("Self Heal"); + e.setTargetingMode(TargetingMode.SELF); + e.setIncludeSelf(true); + e.setInstantHeal(true); + }), + new AbilityVariant("ability.variant.healingAura", a -> { + AbilityEffect e = (AbilityEffect) a; + a.setName("Healing Aura"); + e.setTargetingMode(TargetingMode.AOE_SELF); + e.setTargetFilter(TargetFilter.ALLIES); + e.setIncludeSelf(true); + e.setRadius(8.0f); + e.setBurstEnabled(true); + e.setBurstAmount(5); + e.setBurstDelay(20); + }), + new AbilityVariant("ability.variant.poisonDebuff", a -> { + AbilityEffect e = (AbilityEffect) a; + a.setName("Poison Debuff"); + e.setTargetingMode(TargetingMode.AOE_SELF); + e.setTargetFilter(TargetFilter.ENEMIES); + e.setIncludeSelf(false); + e.setHealAmount(0); + e.setHealPercent(0); + e.setRadius(6.0f); + e.setBurstEnabled(true); + e.setBurstAmount(3); + e.setBurstDelay(40); + }) + ); + } + + // ==================== NBT ==================== + + @Override + public void writeTypeNBT(NBTTagCompound nbt) { + nbt.setInteger("durationTicks", durationTicks); + nbt.setFloat("healAmount", healAmount); + nbt.setFloat("healPercent", healPercent); + nbt.setBoolean("includeSelf", includeSelf); + nbt.setFloat("radius", radius); + nbt.setBoolean("instantHeal", instantHeal); + nbt.setString("targetFilter", targetFilter.name()); + + // Custom effects + NBTTagList ceList = new NBTTagList(); + for (AbilityCustomEffect ce : customEffects) { + ceList.appendTag(ce.writeNBT()); + } + nbt.setTag("customEffects", ceList); + + // Effect actions + NBTTagList eaList = new NBTTagList(); + for (AbilityEffectActionEntry ea : effectActions) { + eaList.appendTag(ea.writeNBT()); + } + nbt.setTag("effectActions", eaList); + } + + @Override + public void readTypeNBT(NBTTagCompound nbt) { + this.durationTicks = nbt.getInteger("durationTicks"); + this.healAmount = nbt.getFloat("healAmount"); + this.healPercent = nbt.getFloat("healPercent"); + this.includeSelf = nbt.getBoolean("includeSelf"); + this.radius = nbt.getFloat("radius"); + this.instantHeal = nbt.getBoolean("instantHeal"); + this.targetFilter = TargetFilter.fromString(nbt.getString("targetFilter")); + + // Custom effects + customEffects.clear(); + if (nbt.hasKey("customEffects")) { + NBTTagList ceList = nbt.getTagList("customEffects", 10); + for (int i = 0; i < ceList.tagCount(); i++) { + AbilityCustomEffect ce = AbilityCustomEffect.fromNBT(ceList.getCompoundTagAt(i)); + if (ce.isValid()) customEffects.add(ce); + } + } + + // Effect actions + effectActions.clear(); + if (nbt.hasKey("effectActions")) { + NBTTagList eaList = nbt.getTagList("effectActions", 10); + for (int i = 0; i < eaList.tagCount(); i++) { + AbilityEffectActionEntry ea = AbilityEffectActionEntry.fromNBT(eaList.getCompoundTagAt(i)); + effectActions.add(ea); + } + } + } + + // ==================== GUI ==================== + + @SideOnly(Side.CLIENT) + @Override + public void getAbilityDefinitions(List defs) { + defs.addAll(Arrays.asList( + FieldDef.boolField("ability.instantHeal", this::isInstantHeal, this::setInstantHeal) + .hover("ability.hover.instant"), + FieldDef.intField("ability.duration", this::getDurationTicks, this::setDurationTicks) + .range(1, 1000).visibleWhen(() -> !this.isInstantHeal()), + FieldDef.section("ability.section.healing"), + FieldDef.row( + FieldDef.floatField("ability.healAmount", this::getHealAmount, this::setHealAmount), + FieldDef.floatField("ability.healPercent", this::getHealPercent, this::setHealPercent) + ), + FieldDef.section("ability.section.targeting") + .tab("Target").visibleWhen(() -> this.targetingMode == TargetingMode.AOE_SELF), + FieldDef.enumField("ability.targetFilter", TargetFilter.class, + this::getTargetFilter, this::setTargetFilter) + .tab("Target").visibleWhen(() -> this.targetingMode == TargetingMode.AOE_SELF), + FieldDef.row( + FieldDef.boolField("ability.includeSelf", this::isIncludeSelf, this::setIncludeSelf) + .visibleWhen(() -> this.targetingMode == TargetingMode.AOE_SELF), + FieldDef.floatField("gui.radius", this::getRadius, this::setRadius) + .visibleWhen(() -> this.targetingMode == TargetingMode.AOE_SELF) + ).tab("Target"), + AbilityFieldDefs.effectsListField("ability.effects", this::getEffects, this::setEffects), + AbilityFieldDefs.customEffectsListField("ability.customEffects", this::getCustomEffects, this::setCustomEffects), + AbilityFieldDefs.effectActionsListField("ability.effectActions", this::getEffectActionEntries, this::setEffectActionEntries) + .visibleWhen(() -> AbilityController.Instance.hasEffectActions()) + )); + } + + // ==================== GETTERS & SETTERS ==================== + + @Override + public int getDurationTicks() { + return durationTicks; + } + + @Override + public void setDurationTicks(int durationTicks) { + this.durationTicks = Math.max(1, durationTicks); + } + + @Override + public float getHealAmount() { + return healAmount; + } + + @Override + public void setHealAmount(float healAmount) { + this.healAmount = healAmount; + } + + @Override + public float getHealPercent() { + return healPercent; + } + + @Override + public void setHealPercent(float healPercent) { + this.healPercent = healPercent; + } + + @Override + public boolean isIncludeSelf() { + return includeSelf; + } + + @Override + public void setIncludeSelf(boolean includeSelf) { + this.includeSelf = includeSelf; + } + + @Override + public float getRadius() { + return radius; + } + + @Override + public void setRadius(float radius) { + this.radius = radius; + } + + @Override + public boolean isInstantHeal() { + return instantHeal; + } + + @Override + public void setInstantHeal(boolean instantHeal) { + this.instantHeal = instantHeal; + } + + public TargetFilter getTargetFilter() { + return targetFilter; + } + + public void setTargetFilter(TargetFilter targetFilter) { + this.targetFilter = targetFilter; + } + + @Override + public int getTargetFilterType() { + return targetFilter.ordinal(); + } + + @Override + public void setTargetFilterType(int filter) { + TargetFilter[] values = TargetFilter.values(); + if (filter >= 0 && filter < values.length) { + this.targetFilter = values[filter]; + } + } + + public List getCustomEffects() { + return customEffects; + } + + public void setCustomEffects(List customEffects) { + this.customEffects = customEffects != null ? customEffects : new ArrayList<>(); + } + + @Override + public int getCustomEffectCount() { + return customEffects.size(); + } + + public List getEffectActionEntries() { + return effectActions; + } + + public void setEffectActionEntries(List effectActions) { + this.effectActions = effectActions != null ? effectActions : new ArrayList<>(); + } + + @Override + public int getEffectActionCount() { + return effectActions.size(); + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityHeal.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityHeal.java deleted file mode 100644 index 89cfd2dbf..000000000 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityHeal.java +++ /dev/null @@ -1,316 +0,0 @@ -package kamkeel.npcs.controllers.data.ability.type; - -import cpw.mods.fml.relauncher.Side; -import cpw.mods.fml.relauncher.SideOnly; -import kamkeel.npcs.controllers.data.ability.Ability; -import kamkeel.npcs.controllers.data.ability.LockMovementType; -import kamkeel.npcs.controllers.data.ability.TargetingMode; -import kamkeel.npcs.controllers.data.telegraph.TelegraphType; -import net.minecraft.entity.Entity; -import net.minecraft.entity.EntityLivingBase; -import net.minecraft.nbt.NBTTagCompound; -import net.minecraft.util.AxisAlignedBB; -import net.minecraft.world.World; -import noppes.npcs.api.ability.type.IAbilityHeal; -import noppes.npcs.client.gui.builder.FieldDef; -import noppes.npcs.entity.EntityNPCInterface; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * Heal ability: Restore health to self or allies. - * Can heal a fixed amount or percentage of max health. - */ -public class AbilityHeal extends Ability implements IAbilityHeal { - - // Type-specific parameters - private int durationTicks = 60; - private float healAmount = 10.0f; - private float healPercent = 0.0f; - private boolean healSelf = true; - private boolean healAllies = false; - private float healRadius = 0.0f; - private boolean instantHeal = true; - - // Runtime state - private transient List healedAllies; - - private List getHealedAllies() { - if (healedAllies == null) { - healedAllies = new ArrayList<>(); - } - return healedAllies; - } - - public AbilityHeal() { - this.typeId = "ability.cnpc.heal"; - this.name = "Heal"; - this.targetingMode = TargetingMode.SELF; - this.lockMovement = LockMovementType.WINDUP; - this.cooldownTicks = 0; - this.windUpTicks = 30; - // No telegraph for heal - it's a self/ally buff - this.telegraphType = TelegraphType.NONE; - this.showTelegraph = false; - } - - @Override - public boolean hasDamage() { - return false; - } - - @Override - public boolean allowBurst() { - return false; - } - - @Override - public boolean isTargetingModeLocked() { - return true; - } - - @Override - public TargetingMode[] getAllowedTargetingModes() { - return new TargetingMode[]{TargetingMode.SELF}; - } - - @Override - public void onExecute(EntityLivingBase caster, EntityLivingBase target) { - if (caster.worldObj.isRemote && !isPreview()) return; - - if (!isPreview()) { - getHealedAllies().clear(); - - // Always find allies if we're healing them (needed for both instant and HoT) - if (healAllies && healRadius > 0) { - findAlliesInRadius(caster, caster.worldObj); - } - - if (instantHeal) { - // Instant heal - apply all healing now - if (healSelf) { - healEntity(caster); - spawnHealParticles(caster.worldObj, caster); - } - - for (EntityLivingBase ally : getHealedAllies()) { - healEntity(ally); - spawnHealParticles(caster.worldObj, ally); - } - } - } - - if (instantHeal) { - // Instant heal completes immediately - signalCompletion(); - } - // If not instant, HoT healing is applied in onActiveTick - } - - @Override - public void onActiveTick(EntityLivingBase caster, EntityLivingBase target, int tick) { - if ((caster.worldObj.isRemote && !isPreview()) || instantHeal) return; - - // Heal over time - distribute heal across duration ticks - if (tick % 10 == 0) { - // Calculate tick-based heal for fixed amount - float tickHeal = (healAmount / (float) durationTicks) * 10; - - if (healSelf) { - float selfTickHeal = tickHeal; - // Add percentage-based heal portion - if (healPercent > 0) { - selfTickHeal += (caster.getMaxHealth() * healPercent / (float) durationTicks) * 10; - } - caster.heal(selfTickHeal); - if (tick % 20 == 0) { - spawnHealParticles(caster.worldObj, caster); - } - } - - if (healAllies && healRadius > 0) { - for (EntityLivingBase ally : getHealedAllies()) { - if (!ally.isDead) { - float allyTickHeal = tickHeal; - // Add percentage-based heal portion for each ally - if (healPercent > 0) { - allyTickHeal += (ally.getMaxHealth() * healPercent / (float) durationTicks) * 10; - } - ally.heal(allyTickHeal); - if (tick % 20 == 0) { - spawnHealParticles(caster.worldObj, ally); - } - } - } - } - } - - // Check if heal duration has ended - if (tick >= durationTicks) { - signalCompletion(); - } - } - - /** - * Find all allies in radius and add them to healedAllies list. - */ - private void findAlliesInRadius(EntityLivingBase caster, World world) { - AxisAlignedBB aabb = AxisAlignedBB.getBoundingBox( - caster.posX - healRadius, caster.posY - 2, caster.posZ - healRadius, - caster.posX + healRadius, caster.posY + 3, caster.posZ + healRadius - ); - - @SuppressWarnings("unchecked") - List entities = world.getEntitiesWithinAABB(EntityLivingBase.class, aabb); - - for (Entity entity : entities) { - if (!(entity instanceof EntityLivingBase)) continue; - if (entity == caster) continue; - - EntityLivingBase living = (EntityLivingBase) entity; - - // Only heal other NPCs (allies) - if (living instanceof EntityNPCInterface) { - float dist = caster.getDistanceToEntity(living); - if (dist <= healRadius) { - getHealedAllies().add(living); - } - } - } - } - - private void healEntity(EntityLivingBase entity) { - float totalHeal = healAmount; - if (healPercent > 0) { - totalHeal += entity.getMaxHealth() * healPercent; - } - entity.heal(totalHeal); - } - - private void spawnHealParticles(World world, EntityLivingBase entity) { - for (int i = 0; i < 10; i++) { - double offsetX = (world.rand.nextDouble() - 0.5) * entity.width; - double offsetY = world.rand.nextDouble() * entity.height; - double offsetZ = (world.rand.nextDouble() - 0.5) * entity.width; - world.spawnParticle("happyVillager", - entity.posX + offsetX, - entity.posY + offsetY, - entity.posZ + offsetZ, - 0, 0.1, 0); - } - } - - @Override - public void cleanup() { - getHealedAllies().clear(); - } - - @Override - public float getTelegraphRadius() { - return healRadius > 0 ? healRadius : 0; - } - - @Override - public void writeTypeNBT(NBTTagCompound nbt) { - nbt.setInteger("durationTicks", durationTicks); - nbt.setFloat("healAmount", healAmount); - nbt.setFloat("healPercent", healPercent); - nbt.setBoolean("healSelf", healSelf); - nbt.setBoolean("healAllies", healAllies); - nbt.setFloat("healRadius", healRadius); - nbt.setBoolean("instantHeal", instantHeal); - } - - @Override - public void readTypeNBT(NBTTagCompound nbt) { - this.durationTicks = nbt.getInteger("durationTicks"); - this.healAmount = nbt.getFloat("healAmount"); - this.healPercent = nbt.getFloat("healPercent"); - this.healSelf = nbt.getBoolean("healSelf"); - this.healAllies = nbt.getBoolean("healAllies"); - this.healRadius = nbt.getFloat("healRadius"); - this.instantHeal = nbt.getBoolean("instantHeal"); - } - - // Getters & Setters - public int getDurationTicks() { - return durationTicks; - } - - public void setDurationTicks(int durationTicks) { - this.durationTicks = Math.max(1, durationTicks); - } - - public float getHealAmount() { - return healAmount; - } - - public void setHealAmount(float healAmount) { - this.healAmount = healAmount; - } - - public float getHealPercent() { - return healPercent; - } - - public void setHealPercent(float healPercent) { - this.healPercent = healPercent; - } - - public boolean isHealSelf() { - return healSelf; - } - - public void setHealSelf(boolean healSelf) { - this.healSelf = healSelf; - } - - public boolean isHealAllies() { - return healAllies; - } - - public void setHealAllies(boolean healAllies) { - this.healAllies = healAllies; - } - - public float getHealRadius() { - return healRadius; - } - - public void setHealRadius(float healRadius) { - this.healRadius = healRadius; - } - - public boolean isInstantHeal() { - return instantHeal; - } - - public void setInstantHeal(boolean instantHeal) { - this.instantHeal = instantHeal; - } - - @SideOnly(Side.CLIENT) - @Override - public void getAbilityDefinitions(List defs) { - defs.addAll(Arrays.asList( - FieldDef.boolField("ability.instantHeal", this::isInstantHeal, this::setInstantHeal) - .hover("ability.hover.instant"), - FieldDef.intField("ability.duration", this::getDurationTicks, this::setDurationTicks) - .range(1, 1000).visibleWhen(() -> !this.isInstantHeal()), - FieldDef.section("ability.section.healing"), - FieldDef.row( - FieldDef.floatField("ability.healAmount", this::getHealAmount, this::setHealAmount), - FieldDef.floatField("ability.healPercent", this::getHealPercent, this::setHealPercent) - ), - FieldDef.floatField("ability.healRadius", this::getHealRadius, this::setHealRadius), - FieldDef.row( - FieldDef.boolField("ability.healSelf", this::isHealSelf, this::setHealSelf) - .hover("ability.hover.healSelf"), - FieldDef.boolField("ability.healAllies", this::isHealAllies, this::setHealAllies) - .hover("ability.hover.healAllies") - ) - )); - } -} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityHeavyHit.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityHeavyHit.java index d7314b707..740f97be8 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityHeavyHit.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityHeavyHit.java @@ -3,7 +3,9 @@ package kamkeel.npcs.controllers.data.ability.type; import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; import kamkeel.npcs.controllers.data.ability.Ability; +import kamkeel.npcs.controllers.data.ability.AbilityTargetHelper; import kamkeel.npcs.controllers.data.ability.LockMovementType; +import kamkeel.npcs.controllers.data.ability.TargetFilter; import kamkeel.npcs.controllers.data.ability.TargetingMode; import kamkeel.npcs.controllers.data.ability.gui.AbilityFieldDefs; import kamkeel.npcs.controllers.data.telegraph.TelegraphType; @@ -106,6 +108,7 @@ public class AbilityHeavyHit extends Ability implements IAbilityHeavyHit { for (Entity entity : entities) { if (!(entity instanceof EntityLivingBase) || entity == caster) continue; EntityLivingBase livingTarget = (EntityLivingBase) entity; + if (!AbilityTargetHelper.shouldAffect(caster, livingTarget, TargetFilter.ENEMIES, false)) continue; double dx = livingTarget.posX - caster.posX; double dz = livingTarget.posZ - caster.posZ; diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityShockwave.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityShockwave.java index 4cca57508..d600e006d 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityShockwave.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityShockwave.java @@ -3,7 +3,9 @@ package kamkeel.npcs.controllers.data.ability.type; import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; import kamkeel.npcs.controllers.data.ability.Ability; +import kamkeel.npcs.controllers.data.ability.AbilityTargetHelper; import kamkeel.npcs.controllers.data.ability.LockMovementType; +import kamkeel.npcs.controllers.data.ability.TargetFilter; import kamkeel.npcs.controllers.data.ability.TargetingMode; import kamkeel.npcs.controllers.data.ability.gui.AbilityFieldDefs; import kamkeel.npcs.controllers.data.telegraph.TelegraphType; @@ -72,6 +74,7 @@ public class AbilityShockwave extends Ability implements IAbilityShockwave { for (EntityLivingBase entity : entities) { if (entity == caster) continue; if (entity.isDead) continue; + if (!AbilityTargetHelper.shouldAffect(caster, entity, TargetFilter.ENEMIES, false)) continue; double dist = caster.getDistanceToEntity(entity); if (dist > pushRadius) continue; diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilitySlam.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilitySlam.java index 1e844271f..9f1c64ff5 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilitySlam.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilitySlam.java @@ -3,7 +3,9 @@ package kamkeel.npcs.controllers.data.ability.type; import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; import kamkeel.npcs.controllers.data.ability.Ability; +import kamkeel.npcs.controllers.data.ability.AbilityTargetHelper; import kamkeel.npcs.controllers.data.ability.LockMovementType; +import kamkeel.npcs.controllers.data.ability.TargetFilter; import kamkeel.npcs.controllers.data.ability.TargetingMode; import kamkeel.npcs.controllers.data.ability.gui.AbilityFieldDefs; import kamkeel.npcs.controllers.data.telegraph.Telegraph; @@ -360,13 +362,17 @@ public class AbilitySlam extends Ability implements IAbilitySlam { for (Entity entity : entities) { if (entity instanceof EntityLivingBase && entity != caster) { EntityLivingBase livingTarget = (EntityLivingBase) entity; + if (!AbilityTargetHelper.shouldAffect(caster, livingTarget, TargetFilter.ENEMIES, false)) continue; // Check if actually within radius (bounding box is a cube, we want a circle) double dx = livingTarget.posX - caster.posX; double dz = livingTarget.posZ - caster.posZ; if (dx * dx + dz * dz <= radius * radius) { // Apply damage with scripted event support - applyAbilityDamage(caster, livingTarget, damage, knockbackStrength); + boolean wasHit = applyAbilityDamage(caster, livingTarget, damage, knockbackStrength); + if (wasHit) { + applyEffects(livingTarget); + } } } } diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityVortex.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityVortex.java index d5f68e013..59d2a280f 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityVortex.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityVortex.java @@ -3,7 +3,9 @@ package kamkeel.npcs.controllers.data.ability.type; import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; import kamkeel.npcs.controllers.data.ability.Ability; +import kamkeel.npcs.controllers.data.ability.AbilityTargetHelper; import kamkeel.npcs.controllers.data.ability.LockMovementType; +import kamkeel.npcs.controllers.data.ability.TargetFilter; import kamkeel.npcs.controllers.data.ability.TargetingMode; import kamkeel.npcs.controllers.data.ability.gui.AbilityFieldDefs; import kamkeel.npcs.controllers.data.telegraph.TelegraphType; @@ -91,6 +93,7 @@ public class AbilityVortex extends Ability implements IAbilityVortex { for (EntityLivingBase entity : entities) { if (entity == caster) continue; if (entity.isDead) continue; + if (!AbilityTargetHelper.shouldAffect(caster, entity, TargetFilter.ENEMIES, false)) continue; double dist = caster.getDistanceToEntity(entity); if (dist <= pullRadius) { diff --git a/src/main/java/kamkeel/npcs/entity/EntityAbilityZone.java b/src/main/java/kamkeel/npcs/entity/EntityAbilityZone.java index ac42b3b1d..57445f8fc 100644 --- a/src/main/java/kamkeel/npcs/entity/EntityAbilityZone.java +++ b/src/main/java/kamkeel/npcs/entity/EntityAbilityZone.java @@ -7,6 +7,8 @@ import cpw.mods.fml.relauncher.SideOnly; import io.netty.buffer.ByteBuf; import kamkeel.npcs.controllers.data.ability.Ability; import kamkeel.npcs.controllers.data.ability.AbilityPotionEffect; +import kamkeel.npcs.controllers.data.ability.AbilityTargetHelper; +import kamkeel.npcs.controllers.data.ability.TargetFilter; import net.minecraft.entity.Entity; import net.minecraft.entity.EntityLivingBase; import net.minecraft.entity.player.EntityPlayer; @@ -357,6 +359,7 @@ public class EntityAbilityZone extends Entity implements IEntityAdditionalSpawnD if (owner != null && entity == owner) continue; if (entity.isDead) continue; if (maxTriggers == 1 && triggeredEntities.contains(entity.getUniqueID())) continue; + if (owner instanceof EntityLivingBase && !AbilityTargetHelper.shouldAffect((EntityLivingBase) owner, entity, TargetFilter.ENEMIES, false)) continue; double dx = entity.posX - posX; double dz = entity.posZ - posZ; @@ -391,6 +394,7 @@ public class EntityAbilityZone extends Entity implements IEntityAdditionalSpawnD for (EntityLivingBase entity : entities) { if (owner != null && entity == owner) continue; + if (owner instanceof EntityLivingBase && !AbilityTargetHelper.shouldAffect((EntityLivingBase) owner, entity, TargetFilter.ENEMIES, false)) continue; double dx = entity.posX - posX; double dz = entity.posZ - posZ; if (shape == ZoneShape.SQUARE) { @@ -464,6 +468,7 @@ public class EntityAbilityZone extends Entity implements IEntityAdditionalSpawnD for (EntityLivingBase entity : entities) { if (owner != null && entity == owner && !affectsCaster) continue; if (damagedThisTick.contains(entity.getEntityId())) continue; + if (entity != owner && owner instanceof EntityLivingBase && !AbilityTargetHelper.shouldAffect((EntityLivingBase) owner, entity, TargetFilter.ENEMIES, false)) continue; if (!isInZone(entity)) continue; if (damagePerSecond > 0) { diff --git a/src/main/java/kamkeel/npcs/util/Register.java b/src/main/java/kamkeel/npcs/util/Register.java index 5c28d5e1d..eab517ce9 100644 --- a/src/main/java/kamkeel/npcs/util/Register.java +++ b/src/main/java/kamkeel/npcs/util/Register.java @@ -3,6 +3,7 @@ package kamkeel.npcs.util; import kamkeel.npcs.controllers.AbilityController; import kamkeel.npcs.controllers.data.ability.Ability; import kamkeel.npcs.controllers.data.ability.AbilityVariant; +import kamkeel.npcs.controllers.data.ability.IEffectAction; import noppes.npcs.LogWriter; import noppes.npcs.controllers.AnimationController; import noppes.npcs.controllers.data.Animation; @@ -173,4 +174,37 @@ public class Register { return new Register.Animations(modClass, animationsPath, namespace); } } + + public static class EffectActions extends Register { + + private EffectActions(String namespace) { + super("effect_action", namespace); + } + + public IEffectAction register(String name, IEffectAction action) { + String key = namespace + ":" + name.trim().toLowerCase().replaceAll(" ", "_"); + entries.put(key, () -> action); + return action; + } + + public void register() { + for (Map.Entry> entry : entries.entrySet()) { + AbilityController.Instance.registerEffectAction(entry.getValue().get()); + } + } + + public static Register.EffectActions create(String namespace, String displayName) { + if (!REGISTERED_NAMESPACES.containsKey("effect_action")) + REGISTERED_NAMESPACES.put("effect_action", new ArrayList<>()); + + if (REGISTERED_NAMESPACES.get("effect_action").contains(namespace)) { + LogWriter.error("REGISTER EFFECT ACTIONS: Namespace " + namespace + " already registered!"); + } + + REGISTERED_NAMESPACES.get("effect_action").add(namespace); + NAMESPACE_DISPLAY_NAMES.put(namespace, displayName); + + return new Register.EffectActions(namespace); + } + } } diff --git a/src/main/java/noppes/npcs/AbstractDataAbilities.java b/src/main/java/noppes/npcs/AbstractDataAbilities.java index a206dd693..240714738 100644 --- a/src/main/java/noppes/npcs/AbstractDataAbilities.java +++ b/src/main/java/noppes/npcs/AbstractDataAbilities.java @@ -5,6 +5,7 @@ import kamkeel.npcs.controllers.data.ability.Ability; 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; import kamkeel.npcs.controllers.data.telegraph.TelegraphInstance; import net.minecraft.entity.EntityLivingBase; @@ -13,6 +14,7 @@ import noppes.npcs.controllers.AnimationController; import noppes.npcs.controllers.data.Animation; import java.util.ArrayList; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -74,6 +76,12 @@ public abstract class AbstractDataAbilities { */ 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 concurrentSlots = new ArrayList<>(); + // ═══════════════════════════════════════════════════════════════════ // ABSTRACT METHODS - Subclasses must implement // ═══════════════════════════════════════════════════════════════════ @@ -606,8 +614,12 @@ public abstract class AbstractDataAbilities { return; } } - startChainEntry(chainTarget); + if (startChainEntry(chainTarget)) { + launchConsecutiveConcurrentEntries(chainTarget); + } } + // Tick concurrent slots even during chain delay + tickConcurrentSlots(); return; } @@ -747,6 +759,9 @@ public abstract class AbstractDataAbilities { // Post-phase hook (NPC: hit scan + movement control) onPostPhaseTick(currentAbility, target); + + // Tick concurrent slots independently + tickConcurrentSlots(); } // ═══════════════════════════════════════════════════════════════════ @@ -784,6 +799,10 @@ public abstract class AbstractDataAbilities { 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) { @@ -884,6 +903,9 @@ public abstract class AbstractDataAbilities { chainDelayRemaining = -1; } + // Interrupt all concurrent slots + interruptConcurrentSlots(); + // Let subclass handle cleanup onInterruptComplete(); } @@ -915,7 +937,11 @@ public abstract class AbstractDataAbilities { chainDelayRemaining = -1; // AFTER semantics: no delay before first entry (delay applies after each entry completes) - return startChainEntry(target); + boolean started = startChainEntry(target); + if (started) { + launchConsecutiveConcurrentEntries(target); + } + return started; } /** @@ -983,8 +1009,74 @@ public abstract class AbstractDataAbilities { 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 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 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) { diff --git a/src/main/java/noppes/npcs/DataAbilities.java b/src/main/java/noppes/npcs/DataAbilities.java index 988a2f073..967ca0dbb 100644 --- a/src/main/java/noppes/npcs/DataAbilities.java +++ b/src/main/java/noppes/npcs/DataAbilities.java @@ -326,10 +326,11 @@ public class DataAbilities extends AbstractDataAbilities { if (rotationLocked || hitScanActive) releaseRotationControl(); if (positionLocked) releaseLockedPosition(); } - // Clear chain state + // Clear chain and concurrent state currentChain = null; chainEntryIndex = -1; chainDelayRemaining = -1; + interruptConcurrentSlots(); return; } @@ -724,6 +725,7 @@ public class DataAbilities extends AbstractDataAbilities { public void reset() { stopCurrentAbility(); clearActiveToggles(); + interruptConcurrentSlots(); // Roll cooldown so NPC doesn't immediately attack after reset rollCooldownOnReset(); diff --git a/src/main/java/noppes/npcs/client/gui/advanced/GuiNPCAbilities.java b/src/main/java/noppes/npcs/client/gui/advanced/GuiNPCAbilities.java index 0a83895ed..d1133947e 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/GuiNPCAbilities.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/GuiNPCAbilities.java @@ -34,6 +34,7 @@ import noppes.npcs.constants.EnumScrollData; import noppes.npcs.entity.EntityNPCInterface; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -187,7 +188,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC } availableTypesScroll.guiLeft = guiLeft + 5; availableTypesScroll.guiTop = y + 12; - availableTypesScroll.setList(getFilteredTypeList()); + availableTypesScroll.setUnsortedList(getFilteredTypeList()); addScroll(availableTypesScroll); // Search bar for types @@ -541,7 +542,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC filteredAbilityTypes.putAll(getFilteredData(allAbilityTypes)); if (availableTypesScroll != null) { - availableTypesScroll.setList(getFilteredTypeList()); + availableTypesScroll.setUnsortedList(getFilteredTypeList()); } } else if (type == EnumScrollData.CUSTOM_ABILITIES) { existingPresetNames.clear(); @@ -605,7 +606,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC if (search.equals(getTextField(TF_SEARCH).getText())) return; search = getTextField(TF_SEARCH).getText().toLowerCase(); - availableTypesScroll.setList(getFilteredTypeList()); + availableTypesScroll.setUnsortedList(getFilteredTypeList()); availableTypesScroll.resetScroll(); } } @@ -640,16 +641,21 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC displayNameToTypeId.clear(); for (String typeId : filteredAbilityTypes.keySet()) { String displayName = I18n.format(typeId); - // Search matches either the display name or the typeId - if (search.isEmpty() || displayName.toLowerCase().contains(search) || typeId.toLowerCase().contains(search)) { - // Gray out built-in types to distinguish them from configurable types - if (AbilityController.Instance.isBuiltInType(typeId)) { + // Search matches either the display name or the typeId (strip color codes for matching) + String stripped = displayName.replaceAll("\u00A7.", ""); + if (search.isEmpty() || stripped.toLowerCase().contains(search) || typeId.toLowerCase().contains(search)) { + // Yellow for concurrent-capable types, gray for other built-in types + if (AbilityController.Instance.isConcurrentCapableType(typeId)) { + displayName = "\u00A7e" + displayName; + } else if (AbilityController.Instance.isBuiltInType(typeId)) { displayName = "\u00A77" + displayName; } list.add(displayName); displayNameToTypeId.put(displayName, typeId); } } + Collections.sort(list, (a, b) -> String.CASE_INSENSITIVE_ORDER.compare( + a.replaceAll("\u00A7.", ""), b.replaceAll("\u00A7.", ""))); return list; } diff --git a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityConfig.java b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityConfig.java index f662da835..3aa153f59 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityConfig.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityConfig.java @@ -144,9 +144,12 @@ public class SubGuiAbilityConfig extends SubGuiInterface implements ITextfieldLi int y = 5; int labelCounter = LABEL_ID_START; - // General tab: type label at top — need to reserve space before building + // General tab: type label + optional concurrent label at top — reserve space if (activeTab == TAB_GENERAL) { y += ROW_H; + if (ability.isConcurrentCapable()) { + y += 15; + } } // Build declarative fields into an auto-created scroll window @@ -157,10 +160,15 @@ public class SubGuiAbilityConfig extends SubGuiInterface implements ITextfieldLi GuiScrollWindow sw = builder.buildScrollWindow(tabFields, swX, swY, swW, swH); - // General tab: add type label at top of scroll window + // General tab: add type label + optional concurrent label at top of scroll window if (activeTab == TAB_GENERAL) { - sw.addLabel(new GuiNpcLabel(builder.getNextLabelId(), "gui.type", 5, 5 + 5, 0xFFFFFF)); - sw.addLabel(new GuiNpcLabel(builder.getNextLabelId() + 1, ability.getTypeId(), 55, 5 + 5, 0xFFFFFF)); + int baseId = builder.getNextLabelId(); + sw.addLabel(new GuiNpcLabel(baseId, "gui.type", 5, 10, 0xFFFFFF)); + sw.addLabel(new GuiNpcLabel(baseId + 1, ability.getTypeId(), 55, 10, 0xFFFFFF)); + if (ability.isConcurrentCapable()) { + sw.addLabel(new GuiNpcLabel(baseId + 2, + StatCollector.translateToLocal("ability.concurrentInfo"), 5, 25, 0x55FF55)); + } } // Conditions on target tab — appended after field defs diff --git a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityTypeSelect.java b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityTypeSelect.java index 99d1fff54..daef52065 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityTypeSelect.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityTypeSelect.java @@ -11,6 +11,7 @@ import noppes.npcs.client.gui.util.ITextfieldListener; import noppes.npcs.client.gui.util.SubGuiInterface; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -47,7 +48,7 @@ public class SubGuiAbilityTypeSelect extends SubGuiInterface implements ICustomS addScroll(scroll); buildAllTypes(); - scroll.setList(getFilteredTypeList()); + scroll.setUnsortedList(getFilteredTypeList()); addButton(new GuiNpcButton(0, guiLeft + 5, guiTop + 188, 90, 20, "gui.add")); getButton(0).setEnabled(scroll.hasSelected()); @@ -59,6 +60,9 @@ public class SubGuiAbilityTypeSelect extends SubGuiInterface implements ICustomS String[] types = AbilityController.Instance.getTypes(); for (String typeId : types) { String displayName = I18n.format(typeId); + if (AbilityController.Instance.isConcurrentCapableType(typeId)) { + displayName = "\u00A7e" + displayName; + } allDisplayNameToTypeId.put(displayName, typeId); } } @@ -68,11 +72,14 @@ public class SubGuiAbilityTypeSelect extends SubGuiInterface implements ICustomS displayNameToTypeId.clear(); for (Map.Entry entry : allDisplayNameToTypeId.entrySet()) { String displayName = entry.getKey(); - if (search.isEmpty() || displayName.toLowerCase().contains(search) || entry.getValue().toLowerCase().contains(search)) { + String stripped = displayName.replaceAll("\u00A7.", ""); + if (search.isEmpty() || stripped.toLowerCase().contains(search) || entry.getValue().toLowerCase().contains(search)) { list.add(displayName); displayNameToTypeId.put(displayName, entry.getValue()); } } + Collections.sort(list, (a, b) -> String.CASE_INSENSITIVE_ORDER.compare( + a.replaceAll("\u00A7.", ""), b.replaceAll("\u00A7.", ""))); return list; } diff --git a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiChainedAbilityConfig.java b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiChainedAbilityConfig.java index ba3bfb955..90326b533 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiChainedAbilityConfig.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiChainedAbilityConfig.java @@ -144,7 +144,7 @@ public class SubGuiChainedAbilityConfig extends SubGuiInterface implements IText // Computed "Valid For" label (derived from child abilities) fieldDefs.add(FieldDef.labelField("ability.validFor", () -> { UserType ut = computeAllowedBy(); - return StatCollector.translateToLocal("ability.validFor") + ": \u00A7e" + StatCollector.translateToLocal("ability.userType." + ut.name()); + return "\u00A7e" + StatCollector.translateToLocal("ability.userType." + ut.name()); }).tab("General")); fieldDefs.add(FieldDef.boolField("gui.enabled", chain::isEnabled, chain::setEnabled).tab("General")); fieldDefs.add(FieldDef.intField("ability.weight", chain::getWeight, chain::setWeight).range(1, 100).tab("General")); @@ -325,12 +325,22 @@ public class SubGuiChainedAbilityConfig extends SubGuiInterface implements IText sw.addButton(entryBtn); if (!readOnlyEntries) { + // Check if this entry is concurrent-active (delay disabled) + Ability resolvedAbility = entry.resolve(); + boolean isConcurrentActive = resolvedAbility != null + && resolvedAbility.isConcurrentCapable() + && entry.isConcurrentEnabled(); + // Delay label + field - sw.addLabel(new GuiNpcLabel(labelCounter++, "ability.delay", L_LABEL_X + 160, y + 5, 0xAAAAAA)); + sw.addLabel(new GuiNpcLabel(labelCounter++, "ability.delay", L_LABEL_X + 160, y + 5, + isConcurrentActive ? 0x555555 : 0xAAAAAA)); GuiNpcTextField delayField = new GuiNpcTextField(ENTRY_BASE + i * ENTRY_STRIDE + 1, this, fontRendererObj, L_LABEL_X + 195, y, 40, 20, String.valueOf(entry.getDelayTicks())); delayField.setIntegersOnly(); delayField.setMinMaxDefault(0, 6000, 0); + if (isConcurrentActive) { + delayField.setEnabled(false); + } sw.addTextField(delayField); // Up button @@ -343,8 +353,16 @@ public class SubGuiChainedAbilityConfig extends SubGuiInterface implements IText sw.addButton(new GuiNpcButton(ENTRY_BASE + i * ENTRY_STRIDE + 3, L_LABEL_X + 263, y, 20, 20, "\u2193")); } + // Concurrent toggle (only shown if resolved ability is concurrent-capable, never for first entry) + if (i > 0 && resolvedAbility != null && resolvedAbility.isConcurrentCapable()) { + String cLabel = entry.isConcurrentEnabled() ? "\u00A7aC" : "\u00A77C"; + GuiNpcButton cBtn = new GuiNpcButton(ENTRY_BASE + i * ENTRY_STRIDE + 5, L_LABEL_X + 288, y, 20, 20, cLabel); + cBtn.setHoverText("ability.hover.concurrent"); + sw.addButton(cBtn); + } + // Delete button - sw.addButton(new GuiNpcButton(ENTRY_BASE + i * ENTRY_STRIDE + 4, L_LABEL_X + 288, y, 20, 20, "X")); + sw.addButton(new GuiNpcButton(ENTRY_BASE + i * ENTRY_STRIDE + 4, L_LABEL_X + 313, y, 20, 20, "X")); } else { // Read-only: show delay as label only int delay = entry.getDelayTicks(); @@ -549,6 +567,10 @@ public class SubGuiChainedAbilityConfig extends SubGuiInterface implements IText if (entryIndex > 0) { ChainedAbilityEntry entry = entries.remove(entryIndex); entries.add(entryIndex - 1, entry); + // Can't be concurrent at position 0 + if (entryIndex - 1 == 0) { + entry.setConcurrentEnabled(false); + } initGui(); } return true; @@ -563,6 +585,15 @@ public class SubGuiChainedAbilityConfig extends SubGuiInterface implements IText entries.remove(entryIndex); initGui(); return true; + case 5: // Concurrent toggle + ChainedAbilityEntry toggleEntry = entries.get(entryIndex); + boolean newState = !toggleEntry.isConcurrentEnabled(); + toggleEntry.setConcurrentEnabled(newState); + if (newState) { + toggleEntry.setDelayTicks(0); + } + initGui(); + return true; } } diff --git a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiCustomEffectSelect.java b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiCustomEffectSelect.java new file mode 100644 index 000000000..093723cae --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiCustomEffectSelect.java @@ -0,0 +1,149 @@ +package noppes.npcs.client.gui.advanced; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import net.minecraft.client.gui.GuiButton; +import noppes.npcs.client.gui.util.GuiCustomScroll; +import noppes.npcs.client.gui.util.GuiNpcButton; +import noppes.npcs.client.gui.util.GuiNpcTextField; +import noppes.npcs.client.gui.util.ICustomScrollListener; +import noppes.npcs.client.gui.util.ITextfieldListener; +import noppes.npcs.client.gui.util.SubGuiInterface; +import noppes.npcs.controllers.CustomEffectController; +import noppes.npcs.controllers.data.CustomEffect; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * SubGui for selecting a custom effect from CustomEffectController. + */ +@SideOnly(Side.CLIENT) +public class SubGuiCustomEffectSelect extends SubGuiInterface implements ICustomScrollListener, ITextfieldListener { + + private GuiCustomScroll scroll; + private final HashMap displayNameToId = new HashMap<>(); + private final HashMap allDisplayNameToId = new HashMap<>(); + private int selectedEffectId = -1; + private String search = ""; + private final int preselectedId; + + public SubGuiCustomEffectSelect(int preselectedId) { + this.preselectedId = preselectedId; + setBackground("menubg.png"); + xSize = 200; + ySize = 216; + } + + @Override + public void initGui() { + super.initGui(); + + addTextField(new GuiNpcTextField(10, this, fontRendererObj, guiLeft + 5, guiTop + 5, 190, 18, search)); + + if (scroll == null) { + scroll = new GuiCustomScroll(this, 0); + scroll.setSize(190, 145); + } + scroll.guiLeft = guiLeft + 5; + scroll.guiTop = guiTop + 26; + addScroll(scroll); + + buildAllEffects(); + List filtered = getFilteredList(); + scroll.setUnsortedList(filtered); + + // Pre-select current effect + if (preselectedId >= 0 && !scroll.hasSelected()) { + for (String name : filtered) { + Integer id = displayNameToId.get(name); + if (id != null && id == preselectedId) { + scroll.setSelected(name); + break; + } + } + } + + addButton(new GuiNpcButton(0, guiLeft + 5, guiTop + 188, 90, 20, "gui.select")); + getButton(0).setEnabled(scroll.hasSelected()); + addButton(new GuiNpcButton(1, guiLeft + 105, guiTop + 188, 90, 20, "gui.cancel")); + } + + private void buildAllEffects() { + allDisplayNameToId.clear(); + HashMap effects = CustomEffectController.getInstance().getCustomEffects(); + if (effects == null) return; + for (CustomEffect ce : effects.values()) { + if (ce.getName() != null && !ce.getName().isEmpty()) { + allDisplayNameToId.put(ce.getName(), ce.id); + } + } + } + + private List getFilteredList() { + List list = new ArrayList<>(); + displayNameToId.clear(); + for (Map.Entry entry : allDisplayNameToId.entrySet()) { + String name = entry.getKey(); + if (search.isEmpty() || name.toLowerCase().contains(search)) { + list.add(name); + displayNameToId.put(name, entry.getValue()); + } + } + Collections.sort(list, String.CASE_INSENSITIVE_ORDER); + return list; + } + + @Override + public void buttonEvent(GuiButton guibutton) { + if (guibutton.id == 0 && scroll.hasSelected()) { + String name = scroll.getSelected(); + Integer id = displayNameToId.get(name); + selectedEffectId = id != null ? id : -1; + close(); + } else if (guibutton.id == 1) { + selectedEffectId = -1; + close(); + } + } + + @Override + public void customScrollClicked(int i, int j, int k, GuiCustomScroll guiCustomScroll) { + if (guiCustomScroll.id == 0) { + getButton(0).setEnabled(scroll.hasSelected()); + } + } + + @Override + public void customScrollDoubleClicked(String selection, GuiCustomScroll guiCustomScroll) { + if (guiCustomScroll.id == 0 && selection != null) { + Integer id = displayNameToId.get(selection); + selectedEffectId = id != null ? id : -1; + close(); + } + } + + @Override + public void keyTyped(char c, int i) { + super.keyTyped(c, i); + if (getTextField(10) != null && getTextField(10).isFocused()) { + String newSearch = getTextField(10).getText().toLowerCase(); + if (!search.equals(newSearch)) { + search = newSearch; + scroll.setUnsortedList(getFilteredList()); + scroll.resetScroll(); + } + } + } + + @Override + public void unFocused(GuiNpcTextField textField) { + } + + public int getSelectedEffectId() { + return selectedEffectId; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/builder/FieldType.java b/src/main/java/noppes/npcs/client/gui/builder/FieldType.java index 052e2f4cd..c00ff1855 100644 --- a/src/main/java/noppes/npcs/client/gui/builder/FieldType.java +++ b/src/main/java/noppes/npcs/client/gui/builder/FieldType.java @@ -15,5 +15,7 @@ public enum FieldType { SECTION_HEADER, LABEL, EFFECTS_LIST, + CUSTOM_EFFECTS_LIST, + EFFECT_ACTIONS_LIST, ROW } diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java index 2ed02955a..d2afe1c64 100644 --- a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java +++ b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java @@ -635,10 +635,11 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA releaseLockedPosition(); } - // Clear chain state + // Clear chain and concurrent state currentChain = null; chainEntryIndex = -1; chainDelayRemaining = -1; + interruptConcurrentSlots(); // Clear all transient state currentAbility = null; diff --git a/src/main/resources/assets/customnpcs/lang/en_US.lang b/src/main/resources/assets/customnpcs/lang/en_US.lang index 6574f71bc..b963e5d34 100644 --- a/src/main/resources/assets/customnpcs/lang/en_US.lang +++ b/src/main/resources/assets/customnpcs/lang/en_US.lang @@ -1650,7 +1650,7 @@ ability.cnpc.teleport=Teleport ability.cnpc.vortex=Vortex ability.cnpc.shockwave=Shockwave ability.cnpc.guard=Guard -ability.cnpc.heal=Heal +ability.cnpc.effect=Effect ability.cnpc.hazard=Hazard ability.cnpc.trap=Trap ability.cnpc.disc=Disc @@ -1843,13 +1843,25 @@ ability.pushStrength=Push Strength ability.damageReduction=Damage Reduction ability.counterWindow=Counter Ticks -# Heal -ability.instantHeal=Instant Heal +# Effect +ability.instantHeal=Instant ability.healAmount=Heal Amount ability.healPercent=Heal Percent -ability.healRadius=Heal Radius -ability.healSelf=Heal Self -ability.healAllies=Heal Allies +ability.targetFilter=Target Filter +ability.targetFilter.allies=Allies +ability.targetFilter.enemies=Enemies +ability.targetFilter.all=All +ability.includeSelf=Include Self +ability.section.targeting=Targeting +ability.concurrent=Concurrent +ability.concurrentInfo=This ability allows Concurrency in Chains +ability.hover.concurrent=When enabled, this ability fires at the same time as the ability above it in the chain +ability.customEffects=Custom Effects +ability.effectActions=Effect Actions +ability.noCustomEffects=No custom effects defined +ability.variant.selfHeal=Self Heal +ability.variant.healingAura=Healing Aura +ability.variant.poisonDebuff=Poison Debuff # Hazard ability.shape=Shape