diff --git a/src/main/java/kamkeel/npcs/controllers/AbilityController.java b/src/main/java/kamkeel/npcs/controllers/AbilityController.java index 9a55175df..1591c2514 100644 --- a/src/main/java/kamkeel/npcs/controllers/AbilityController.java +++ b/src/main/java/kamkeel/npcs/controllers/AbilityController.java @@ -11,6 +11,7 @@ import kamkeel.npcs.controllers.data.ability.AbilityVariant; import kamkeel.npcs.controllers.data.ability.data.IAbilityAction; import kamkeel.npcs.controllers.data.ability.data.effect.IEffectAction; import kamkeel.npcs.controllers.data.ability.data.ChainedAbility; +import kamkeel.npcs.controllers.data.ability.data.entry.ChainedAbilityEntry; import kamkeel.npcs.controllers.data.ability.enums.AbilityPhase; import kamkeel.npcs.controllers.data.ability.enums.UserType; import kamkeel.npcs.controllers.data.ability.extender.IAbilityExtender; @@ -45,6 +46,7 @@ import noppes.npcs.api.handler.IAbilityHandler; import noppes.npcs.controllers.PlayerDataController; import noppes.npcs.controllers.data.PlayerData; import noppes.npcs.util.NBTJsonUtil; +import kamkeel.npcs.util.FileNameHelper; import java.io.File; import java.util.*; @@ -213,6 +215,7 @@ public class AbilityController implements IAbilityHandler { customAbilities.clear(); customAbilitiesById.clear(); int migrated = 0; + Map migratedAbilityNames = new HashMap(); File dir = getDir(); File[] files = dir.exists() ? dir.listFiles() : null; @@ -221,7 +224,7 @@ public class AbilityController implements IAbilityHandler { if (!file.isFile() || !file.getName().endsWith(".json")) continue; try { String filename = file.getName(); - String key = filename.substring(0, filename.length() - 5); + String fileKey = filename.substring(0, filename.length() - 5); NBTTagCompound nbt = NBTJsonUtil.LoadFile(file); Ability ability = fromNBT(nbt); @@ -234,48 +237,55 @@ public class AbilityController implements IAbilityHandler { continue; } - // Legacy migration: UUID-named files get renamed to name-based - if (UUID_PATTERN.matcher(key).matches()) { - String name = ability.getName(); - if (name == null || name.isEmpty()) { - name = key; // Fallback to UUID if no name stored - } + String storedName = ability.getName(); + String preferredName; + if (UUID_PATTERN.matcher(fileKey).matches()) { + preferredName = (storedName != null && !storedName.isEmpty()) ? storedName : fileKey; + } else { + preferredName = fileKey; + } + String name = FileNameHelper.sanitizeName(preferredName, "Ability"); + name = makeUniqueNameForLoad(customAbilities, dir, file, name, fileKey); - // Ensure unique name - while (hasCustomAbilityName(name) || nameFileExists(dir, name)) { - name = name + "_"; - } - ability.setName(name); - - // Rename file from UUID.json to name.json - File newFile = new File(dir, name + ".json"); - if (file.renameTo(newFile)) { - LogWriter.info("Migrated ability file: " + key + ".json -> " + name + ".json"); + boolean dirty = false; + if (!name.equals(fileKey)) { + File renamedFile = new File(dir, name + ".json"); + if (file.renameTo(renamedFile)) { + LogWriter.info("Migrated ability file: " + fileKey + ".json -> " + name + ".json"); + file = renamedFile; + migrated++; + dirty = true; } else { - LogWriter.error("Failed to rename ability file: " + key + ".json -> " + name + ".json"); + LogWriter.error("Failed to rename ability file: " + fileKey + ".json -> " + name + ".json"); } + } - key = name; - migrated++; + ability.setName(name); + if (storedName != null && !storedName.equals(name)) { + dirty = true; + if (!storedName.isEmpty()) { + migratedAbilityNames.put(storedName, name); + } + } + if (!fileKey.equals(name)) { + migratedAbilityNames.put(fileKey, name); } // Ensure ability has a UUID; generate one for legacy abilities String uuid = ability.getId(); - if (uuid == null || uuid.isEmpty() || uuid.equals(key)) { + if (uuid == null || uuid.isEmpty() || uuid.equals(fileKey) || uuid.equals(name)) { // Legacy: id was the name or empty — generate a real UUID uuid = UUID.randomUUID().toString(); ability.setId(uuid); - // Re-save file with the new UUID - try { - NBTJsonUtil.SaveFile(file, ability.writeNBT()); - } catch (Exception ex) { - LogWriter.error("Failed to save UUID for ability: " + key, ex); - } migrated++; + dirty = true; } - ability.setName(key); - customAbilities.put(key, ability); + if (dirty) { + NBTJsonUtil.SaveFile(file, ability.writeNBT()); + } + + customAbilities.put(name, ability); customAbilitiesById.put(uuid, ability); } catch (Exception e) { LogWriter.error("Error loading custom ability: " + file.getAbsolutePath(), e); @@ -285,23 +295,68 @@ public class AbilityController implements IAbilityHandler { customAbilityRevision++; if (migrated > 0) { - LogWriter.info("Migrated " + migrated + " abilities (UUID-named files or missing UUIDs)"); + LogWriter.info("Migrated " + migrated + " abilities (filename normalization / UUID repair)"); } LogWriter.info("Loaded " + customAbilities.size() + " custom abilities"); - loadChainedAbilities(); + loadChainedAbilities(migratedAbilityNames); } - private boolean nameFileExists(File dir, String name) { - return new File(dir, name + ".json").exists(); + private boolean nameFileExists(File dir, String name, File ignoredFile) { + File existing = new File(dir, name + ".json"); + if (!existing.exists()) return false; + return ignoredFile == null || !existing.getAbsolutePath().equals(ignoredFile.getAbsolutePath()); + } + + private String makeUniqueNameForLoad(Map loadedMap, File dir, File currentFile, String baseName, String selfKey) { + String candidate = baseName; + while ((loadedMap.containsKey(candidate) && !candidate.equals(selfKey)) + || nameFileExists(dir, candidate, currentFile)) { + candidate = candidate + "_"; + } + return candidate; + } + + private String makeUniqueNameForMap(Map map, String baseName, String selfName) { + String candidate = baseName; + while (map.containsKey(candidate) && (selfName == null || !candidate.equals(selfName))) { + candidate = candidate + "_"; + } + return candidate; + } + + private boolean remapChainEntryReferences(ChainedAbility chain, Map migratedAbilityNames) { + if (chain == null || migratedAbilityNames == null || migratedAbilityNames.isEmpty()) return false; + boolean changed = false; + for (ChainedAbilityEntry entry : chain.getEntries()) { + if (entry == null || !entry.isReference()) continue; + String ref = entry.getAbilityReference(); + if (ref == null || ref.isEmpty()) continue; + + String mapped = migratedAbilityNames.get(ref); + if (mapped == null) { + for (Map.Entry e : migratedAbilityNames.entrySet()) { + if (e.getKey().equalsIgnoreCase(ref)) { + mapped = e.getValue(); + break; + } + } + } + + if (mapped != null && !mapped.equals(ref)) { + entry.setAbilityReference(mapped); + changed = true; + } + } + return changed; } public boolean saveCustomAbility(Ability ability) { if (ability == null) return false; if (ability.isBuiltIn()) return false; - String name = ability.getName(); - if (name == null || name.isEmpty()) return false; + String name = FileNameHelper.sanitizeName(ability.getName(), "Ability"); + ability.setName(name); String uuid = ability.getId(); boolean isNew = (uuid == null || uuid.isEmpty()); @@ -312,26 +367,17 @@ public class AbilityController implements IAbilityHandler { ability.setId(uuid); // Ensure unique name - while (hasCustomAbilityName(name)) { - name = name + "_"; - } + name = makeUniqueNameForMap(customAbilities, name, null); ability.setName(name); } else { // Existing ability — check for rename via UUID lookup Ability existing = customAbilitiesById.get(uuid); if (existing != null) { String oldName = existing.getName(); - if (!oldName.equals(name)) { - // Name changed! Ensure new name is unique (excluding self) - String testName = name; - while (customAbilities.containsKey(testName) && !testName.equals(oldName)) { - testName = testName + "_"; - } - if (!testName.equals(name)) { - name = testName; - ability.setName(name); - } + name = makeUniqueNameForMap(customAbilities, name, oldName); + ability.setName(name); + if (!oldName.equals(name)) { // Delete old file and remove old map entry File oldFile = new File(getDir(), oldName + ".json"); if (oldFile.exists()) { @@ -339,6 +385,9 @@ public class AbilityController implements IAbilityHandler { } customAbilities.remove(oldName); } + } else { + name = makeUniqueNameForMap(customAbilities, name, null); + ability.setName(name); } } @@ -374,6 +423,15 @@ public class AbilityController implements IAbilityHandler { if (name == null || name.isEmpty()) return false; Ability removed = customAbilities.remove(name); + if (removed == null) { + String sanitized = FileNameHelper.sanitizeTextInput(name); + if (!sanitized.equals(name)) { + removed = customAbilities.remove(sanitized); + if (removed != null) { + name = sanitized; + } + } + } if (removed == null) return false; // Also remove from UUID index @@ -412,7 +470,13 @@ public class AbilityController implements IAbilityHandler { } public Ability getCustomAbility(String name) { - return customAbilities.get(name); + Ability ability = customAbilities.get(name); + if (ability != null) return ability; + String sanitized = FileNameHelper.sanitizeTextInput(name); + if (!sanitized.equals(name)) { + return customAbilities.get(sanitized); + } + return null; } public Set getCustomAbilityNames() { @@ -452,9 +516,10 @@ public class AbilityController implements IAbilityHandler { // CHAINED ABILITIES // ═══════════════════════════════════════════════════════════════════════════ - private void loadChainedAbilities() { + private void loadChainedAbilities(Map migratedAbilityNames) { chainedAbilities.clear(); chainedAbilitiesById.clear(); + int migrated = 0; File dir = getChainedDir(); File[] files = dir.exists() ? dir.listFiles() : null; @@ -463,19 +528,51 @@ public class AbilityController implements IAbilityHandler { if (!file.isFile() || !file.getName().endsWith(".json")) continue; try { String filename = file.getName(); - String key = filename.substring(0, filename.length() - 5); + String fileKey = filename.substring(0, filename.length() - 5); NBTTagCompound nbt = NBTJsonUtil.LoadFile(file); ChainedAbility chain = new ChainedAbility(); chain.readNBT(nbt); - chain.setName(key); - chainedAbilities.put(key, chain); + String storedName = chain.getName(); + String name = FileNameHelper.sanitizeName(fileKey, "Chain"); + name = makeUniqueNameForLoad(chainedAbilities, dir, file, name, fileKey); + + boolean dirty = false; + if (!name.equals(fileKey)) { + File renamedFile = new File(dir, name + ".json"); + if (file.renameTo(renamedFile)) { + LogWriter.info("Migrated chained ability file: " + fileKey + ".json -> " + name + ".json"); + file = renamedFile; + dirty = true; + } else { + LogWriter.error("Failed to rename chained ability file: " + fileKey + ".json -> " + name + ".json"); + } + } + + chain.setName(name); + if (storedName != null && !storedName.equals(name)) { + dirty = true; + } + + if (remapChainEntryReferences(chain, migratedAbilityNames)) { + dirty = true; + } String uuid = chain.getId(); - if (uuid != null && !uuid.isEmpty()) { - chainedAbilitiesById.put(uuid, chain); + if (uuid == null || uuid.isEmpty() || uuid.equals(fileKey) || uuid.equals(name)) { + uuid = UUID.randomUUID().toString(); + chain.setId(uuid); + dirty = true; } + + if (dirty) { + NBTJsonUtil.SaveFile(file, chain.writeNBT()); + migrated++; + } + + chainedAbilities.put(name, chain); + chainedAbilitiesById.put(uuid, chain); } catch (Exception e) { LogWriter.error("Error loading chained ability: " + file.getAbsolutePath(), e); } @@ -483,14 +580,17 @@ public class AbilityController implements IAbilityHandler { } chainedAbilityRevision++; + if (migrated > 0) { + LogWriter.info("Migrated " + migrated + " chained abilities"); + } LogWriter.info("Loaded " + chainedAbilities.size() + " chained abilities"); } public boolean saveChainedAbility(ChainedAbility chain) { if (chain == null) return false; - String name = chain.getName(); - if (name == null || name.isEmpty()) return false; + String name = FileNameHelper.sanitizeName(chain.getName(), "Chain"); + chain.setName(name); String uuid = chain.getId(); boolean isNew = (uuid == null || uuid.isEmpty()); @@ -499,30 +599,25 @@ public class AbilityController implements IAbilityHandler { uuid = UUID.randomUUID().toString(); chain.setId(uuid); - while (hasChainedAbilityName(name)) { - name = name + "_"; - } + name = makeUniqueNameForMap(chainedAbilities, name, null); chain.setName(name); } else { ChainedAbility existing = chainedAbilitiesById.get(uuid); if (existing != null) { String oldName = existing.getName(); - if (!oldName.equals(name)) { - String testName = name; - while (chainedAbilities.containsKey(testName) && !testName.equals(oldName)) { - testName = testName + "_"; - } - if (!testName.equals(name)) { - name = testName; - chain.setName(name); - } + name = makeUniqueNameForMap(chainedAbilities, name, oldName); + chain.setName(name); + if (!oldName.equals(name)) { File oldFile = new File(getChainedDir(), oldName + ".json"); if (oldFile.exists()) { oldFile.delete(); } chainedAbilities.remove(oldName); } + } else { + name = makeUniqueNameForMap(chainedAbilities, name, null); + chain.setName(name); } } @@ -558,6 +653,15 @@ public class AbilityController implements IAbilityHandler { if (name == null || name.isEmpty()) return false; ChainedAbility removed = chainedAbilities.remove(name); + if (removed == null) { + String sanitized = FileNameHelper.sanitizeTextInput(name); + if (!sanitized.equals(name)) { + removed = chainedAbilities.remove(sanitized); + if (removed != null) { + name = sanitized; + } + } + } if (removed == null) return false; String uuid = removed.getId(); @@ -608,6 +712,12 @@ public class AbilityController implements IAbilityHandler { } } + String sanitizedKey = FileNameHelper.sanitizeTextInput(key); + if (!sanitizedKey.equals(key)) { + ChainedAbility sanitized = chainedAbilities.get(sanitizedKey); + if (sanitized != null) return sanitized.deepCopy(); + } + return null; } @@ -618,12 +728,20 @@ public class AbilityController implements IAbilityHandler { for (String name : chainedAbilities.keySet()) { if (name.equalsIgnoreCase(key)) return true; } + String sanitizedKey = FileNameHelper.sanitizeTextInput(key); + if (!sanitizedKey.equals(key) && chainedAbilities.containsKey(sanitizedKey)) return true; return false; } @Override public ChainedAbility getChainedAbility(String name) { ChainedAbility chain = chainedAbilities.get(name); + if (chain == null) { + String sanitized = FileNameHelper.sanitizeTextInput(name); + if (!sanitized.equals(name)) { + chain = chainedAbilities.get(sanitized); + } + } return chain != null ? chain.deepCopy() : null; } @@ -646,7 +764,9 @@ public class AbilityController implements IAbilityHandler { @Override public boolean hasChainedAbilityName(String name) { - return chainedAbilities.containsKey(name); + if (chainedAbilities.containsKey(name)) return true; + String sanitized = FileNameHelper.sanitizeTextInput(name); + return !sanitized.equals(name) && chainedAbilities.containsKey(sanitized); } @Override @@ -734,6 +854,12 @@ public class AbilityController implements IAbilityHandler { } } + String sanitizedKey = FileNameHelper.sanitizeTextInput(key); + if (!sanitizedKey.equals(key)) { + Ability sanitized = customAbilities.get(sanitizedKey); + if (sanitized != null) return sanitized.deepCopy(); + } + return null; } @@ -778,8 +904,14 @@ public class AbilityController implements IAbilityHandler { } public boolean hasAbility(String key) { - return builtAbilities.containsKey(key) || customAbilities.containsKey(key) - || customAbilitiesById.containsKey(key); + if (builtAbilities.containsKey(key) || customAbilities.containsKey(key) + || customAbilitiesById.containsKey(key)) { + return true; + } + String sanitized = FileNameHelper.sanitizeTextInput(key); + return !sanitized.equals(key) + && (builtAbilities.containsKey(sanitized) || customAbilities.containsKey(sanitized) + || customAbilitiesById.containsKey(sanitized)); } public Ability getCustomAbilityByUUID(String uuid) { @@ -787,7 +919,13 @@ public class AbilityController implements IAbilityHandler { } public Ability getCustomAbilityByName(String name) { - return customAbilities.get(name); + Ability ability = customAbilities.get(name); + if (ability != null) return ability; + String sanitized = FileNameHelper.sanitizeTextInput(name); + if (!sanitized.equals(name)) { + return customAbilities.get(sanitized); + } + return null; } /** @@ -821,6 +959,9 @@ public class AbilityController implements IAbilityHandler { if (ability.getName() != null && ability.getName().equalsIgnoreCase(key)) return true; } + String sanitizedKey = FileNameHelper.sanitizeTextInput(key); + if (!sanitizedKey.equals(key) && customAbilities.containsKey(sanitizedKey)) return true; + return false; } @@ -1128,7 +1269,9 @@ public class AbilityController implements IAbilityHandler { @Override public boolean hasCustomAbilityName(String name) { - return customAbilities.containsKey(name); + if (customAbilities.containsKey(name)) return true; + String sanitized = FileNameHelper.sanitizeTextInput(name); + return !sanitized.equals(name) && customAbilities.containsKey(sanitized); } @Override 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 8bafe49f0..4a8558218 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java @@ -7,6 +7,7 @@ import kamkeel.npcs.controllers.data.ability.data.AbilityIconData; import kamkeel.npcs.controllers.data.ability.data.IAbilityAction; import kamkeel.npcs.controllers.data.ability.enums.AbilityPhase; import kamkeel.npcs.controllers.data.ability.data.effect.AbilityPotionEffect; +import kamkeel.npcs.controllers.data.ability.enums.InvulnerableMode; import kamkeel.npcs.controllers.data.ability.enums.LockMode; import kamkeel.npcs.controllers.data.ability.preview.PreviewEntityHandler; import kamkeel.npcs.controllers.data.ability.util.AbilityTargetHelper; @@ -21,6 +22,7 @@ import kamkeel.npcs.controllers.data.telegraph.TelegraphType; import kamkeel.npcs.entity.EntityAbilityBarrier; import kamkeel.npcs.entity.EntityAbilityDome; import kamkeel.npcs.entity.EntityAbilityPanel; +import kamkeel.npcs.util.FileNameHelper; import net.minecraft.block.Block; import net.minecraft.entity.Entity; import net.minecraft.entity.EntityLivingBase; @@ -85,6 +87,7 @@ public abstract class Ability implements IAbility, IAbilityAction { // Interruption protected boolean interruptible = true; + protected InvulnerableMode invulnerableMode = InvulnerableMode.NONE; // Feedback protected LockMode lockMovement = LockMode.WINDUP; @@ -687,10 +690,24 @@ public abstract class Ability implements IAbility, IAbilityAction { ).tab("General")); defs.add(FieldDef.row( FieldDef.boolField("ability.interruptible", this::isInterruptible, this::setInterruptible) - .hover("ability.hover.interruptible"), + .hover("ability.hover.interruptible") + .enabledWhen(() -> !invulnerableMode.invulnerableDuringWindup()), FieldDef.intField("ability.dazedTicks", this::getDazedTicks, this::setDazedTicks) - .range(0, 1000).visibleWhen(this::isInterruptible) + .range(0, 1000) + .visibleWhen(() -> isInterruptible() && !invulnerableMode.invulnerableDuringWindup()) ).tab("General")); + defs.add(FieldDef.stringEnumField("ability.invulnerable", InvulnerableMode.getDisplayKeys(), + () -> this.getInvulnerableMode().getDisplayKey(), + v -> { + for (InvulnerableMode mode : InvulnerableMode.values()) { + if (mode.getDisplayKey().equals(v)) { + this.setInvulnerableMode(mode); + break; + } + } + }) + .hover("ability.hover.invulnerable") + .tab("General")); // ── Burst section ──────────────────────────────────────────── if (allowBurst()) { @@ -1174,6 +1191,9 @@ public abstract class Ability implements IAbility, IAbilityAction { if (!interruptible || (phase != AbilityPhase.WINDUP && phase != AbilityPhase.BURST_DELAY)) { return false; } + if (isInvulnerableForCurrentPhase()) { + return false; + } // Only direct physical hits can interrupt, not magic, fire, or other indirect damage if (source == null) { @@ -1332,6 +1352,7 @@ public abstract class Ability implements IAbility, IAbilityAction { nbt.setBoolean("syncWindup", syncWindupWithAnimation); nbt.setInteger("recovery", dazedTicks); nbt.setBoolean("interruptible", interruptible); + nbt.setInteger("invulnerableMode", invulnerableMode.ordinal()); nbt.setInteger("lockMovement", lockMovement.ordinal()); nbt.setInteger("rotationMode", rotationMode.ordinal()); nbt.setInteger("rotationPhase", rotationPhase.ordinal()); @@ -1411,6 +1432,9 @@ public abstract class Ability implements IAbility, IAbilityAction { syncWindupWithAnimation = nbt.hasKey("syncWindup") ? nbt.getBoolean("syncWindup") : true; dazedTicks = nbt.hasKey("recovery") ? nbt.getInteger("recovery") : 80; interruptible = nbt.hasKey("interruptible") ? nbt.getBoolean("interruptible") : true; + invulnerableMode = nbt.hasKey("invulnerableMode") + ? InvulnerableMode.fromOrdinal(nbt.getInteger("invulnerableMode")) + : InvulnerableMode.NONE; lockMovement = LockMode.fromOrdinal(nbt.getInteger("lockMovement")); rotationMode = RotationMode.fromOrdinal(nbt.getInteger("rotationMode")); rotationPhase = LockMode.fromOrdinal(nbt.getInteger("rotationPhase")); @@ -1501,7 +1525,12 @@ public abstract class Ability implements IAbility, IAbilityAction { } public void setName(String name) { - this.name = name; + if (builtIn) { + this.name = name != null ? name : ""; + return; + } + String fallback = (this.name != null && !this.name.isEmpty()) ? this.name : "Ability"; + this.name = FileNameHelper.sanitizeName(name, fallback); } /** @@ -1510,7 +1539,9 @@ public abstract class Ability implements IAbility, IAbilityAction { * Converts & color codes to § for rendering. */ public String getDisplayName() { - String result = (displayName != null && !displayName.isEmpty()) ? displayName : name; + String result = (displayName != null && !displayName.isEmpty()) + ? displayName + : FileNameHelper.toDisplayName(name); return result != null ? result.replaceAll("&([0-9a-fk-or])", "\u00A7$1") : ""; } @@ -1664,6 +1695,22 @@ public abstract class Ability implements IAbility, IAbilityAction { this.interruptible = interruptible; } + public InvulnerableMode getInvulnerableMode() { + return invulnerableMode; + } + + public void setInvulnerableMode(InvulnerableMode invulnerableMode) { + this.invulnerableMode = invulnerableMode != null ? invulnerableMode : InvulnerableMode.NONE; + } + + public boolean isInvulnerableDuringWindup() { + return invulnerableMode.invulnerableDuringWindup(); + } + + public boolean isInvulnerableDuringActive() { + return invulnerableMode.invulnerableDuringActive(); + } + public LockMode getLockMovement() { return lockMovement; } @@ -1796,6 +1843,20 @@ public abstract class Ability implements IAbility, IAbilityAction { } } + /** + * Check if this ability grants invulnerability during its current execution phase. + */ + public boolean isInvulnerableForCurrentPhase() { + return invulnerableMode.isInvulnerableInPhase(phase); + } + + /** + * Check if this ability grants invulnerability for a specific phase. + */ + public boolean isInvulnerableForPhase(AbilityPhase phase) { + return invulnerableMode.isInvulnerableInPhase(phase); + } + /** * Check if rotation should be locked during the current phase. */ diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/data/ChainedAbility.java b/src/main/java/kamkeel/npcs/controllers/data/ability/data/ChainedAbility.java index f9927d42e..fe684da20 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/data/ChainedAbility.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/data/ChainedAbility.java @@ -9,6 +9,7 @@ import net.minecraft.entity.EntityLivingBase; import net.minecraft.nbt.NBTTagCompound; import net.minecraft.nbt.NBTTagList; import noppes.npcs.api.ability.IChainedAbility; +import kamkeel.npcs.util.FileNameHelper; import java.util.ArrayList; import java.util.List; @@ -77,7 +78,7 @@ public class ChainedAbility implements IChainedAbility, IAbilityAction { } public ChainedAbility(String name) { - this.name = name != null ? name : ""; + setName(name); } // ═══════════════════════════════════════════════════════════════════ @@ -97,7 +98,8 @@ public class ChainedAbility implements IChainedAbility, IAbilityAction { } public void setName(String name) { - this.name = name != null ? name : ""; + String fallback = (this.name != null && !this.name.isEmpty()) ? this.name : "Chain"; + this.name = FileNameHelper.sanitizeName(name, fallback); } /** @@ -106,7 +108,9 @@ public class ChainedAbility implements IChainedAbility, IAbilityAction { * Converts & color codes to § for rendering. */ public String getDisplayName() { - String result = (displayName != null && !displayName.isEmpty()) ? displayName : name; + String result = (displayName != null && !displayName.isEmpty()) + ? displayName + : FileNameHelper.toDisplayName(name); return result != null ? result.replaceAll("&([0-9a-fk-or])", "\u00A7$1") : ""; } @@ -406,7 +410,7 @@ public class ChainedAbility implements IChainedAbility, IAbilityAction { public void readNBT(NBTTagCompound nbt) { id = nbt.getString("Id"); - name = nbt.getString("Name"); + setName(nbt.getString("Name")); displayName = nbt.getString("DisplayName"); enabled = !nbt.hasKey("Enabled") || nbt.getBoolean("Enabled"); weight = nbt.hasKey("Weight") ? nbt.getInteger("Weight") : 10; diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/enums/InvulnerableMode.java b/src/main/java/kamkeel/npcs/controllers/data/ability/enums/InvulnerableMode.java new file mode 100644 index 000000000..fc6dc5169 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/enums/InvulnerableMode.java @@ -0,0 +1,66 @@ +package kamkeel.npcs.controllers.data.ability.enums; + +/** + * Defines when a caster is invulnerable while an ability is executing. + */ +public enum InvulnerableMode { + NONE, + WINDUP, + ACTIVE, + BOTH; + + public boolean invulnerableDuringWindup() { + return this == WINDUP || this == BOTH; + } + + public boolean invulnerableDuringActive() { + return this == ACTIVE || this == BOTH; + } + + /** + * Treat BURST_DELAY as windup-like for interruption/damage immunity checks. + */ + public boolean isInvulnerableInPhase(AbilityPhase phase) { + if (phase == null) return false; + switch (phase) { + case WINDUP: + case BURST_DELAY: + return invulnerableDuringWindup(); + case ACTIVE: + return invulnerableDuringActive(); + default: + return false; + } + } + + public String getDisplayKey() { + switch (this) { + case WINDUP: + return "ability.invulnerable.windup"; + case ACTIVE: + return "ability.invulnerable.active"; + case BOTH: + return "ability.invulnerable.both"; + case NONE: + default: + return "ability.invulnerable.none"; + } + } + + public static String[] getDisplayKeys() { + return new String[]{ + "ability.invulnerable.none", + "ability.invulnerable.windup", + "ability.invulnerable.active", + "ability.invulnerable.both" + }; + } + + public static InvulnerableMode fromOrdinal(int ordinal) { + InvulnerableMode[] values = values(); + if (ordinal >= 0 && ordinal < values.length) { + return values[ordinal]; + } + return NONE; + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityDash.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityDash.java index fb228fc3e..26600ef0c 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityDash.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityDash.java @@ -91,6 +91,8 @@ public class AbilityDash extends AbilityMovement implements IAbilityDash { // Type-specific runtime state private transient DashDirection chosenDirection; + private transient double preDashMotionX; + private transient double preDashMotionZ; public AbilityDash() { this.typeId = "ability.cnpc.dash"; @@ -125,6 +127,8 @@ public class AbilityDash extends AbilityMovement implements IAbilityDash { @Override public void onExecute(EntityLivingBase caster, EntityLivingBase target) { + preDashMotionX = caster.motionX; + preDashMotionZ = caster.motionZ; initMovement(caster, dashDistance, dashSpeed); if (dashMode == DashMode.DIRECTIONAL) { @@ -151,37 +155,46 @@ public class AbilityDash extends AbilityMovement implements IAbilityDash { @Override public void onActiveTick(EntityLivingBase caster, EntityLivingBase target, int tick) { if (checkTimeout(tick)) { - stopMomentum(caster); + finishDash(caster); signalCompletion(); return; } if (movementDirection == null) { + finishDash(caster); signalCompletion(); return; } if (checkStall(caster, tick)) { - stopMomentum(caster); + finishDash(caster); signalCompletion(); return; } updatePrevPosition(caster); if (getDistanceTraveledSq(caster) >= (double) dashDistance * dashDistance) { - stopMomentum(caster); + finishDash(caster); signalCompletion(); return; } if (checkBlocked(caster, dashSpeed)) { - stopMomentum(caster); + finishDash(caster); signalCompletion(); return; } - // Move caster (motionY left to gravity for skip arc) - applyVelocity(caster, dashSpeed); + // Move caster (motionY left to gravity for skip arc). + // Directional mode layers the dash over incoming movement so momentum + // entering/exiting the dash stays fluid. + if (dashMode == DashMode.DIRECTIONAL) { + applyHorizontalMomentum(caster, + preDashMotionX + movementDirection.xCoord * dashSpeed, + preDashMotionZ + movementDirection.zCoord * dashSpeed); + } else { + applyVelocity(caster, dashSpeed); + } if (!isPreview()) { // Trail particles @@ -191,24 +204,28 @@ public class AbilityDash extends AbilityMovement implements IAbilityDash { @Override public void onComplete(EntityLivingBase caster, EntityLivingBase target) { - stopMomentum(caster); + finishDash(caster); } @Override public void onInterrupt(EntityLivingBase caster, DamageSource source, float damage) { - stopMomentum(caster); + finishDash(caster); } @Override public void cleanup() { super.cleanup(); chosenDirection = null; + preDashMotionX = 0; + preDashMotionZ = 0; } @Override public void resetForBurst() { super.resetForBurst(); chosenDirection = null; + preDashMotionX = 0; + preDashMotionZ = 0; } @Override @@ -279,6 +296,14 @@ public class AbilityDash extends AbilityMovement implements IAbilityDash { return chosenDirection; } + private void finishDash(EntityLivingBase caster) { + if (dashMode == DashMode.DIRECTIONAL) { + applyHorizontalMomentum(caster, preDashMotionX, preDashMotionZ); + } else { + stopMomentum(caster); + } + } + @SideOnly(Side.CLIENT) @Override public void getAbilityDefinitions(List defs) { diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityMovement.java b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityMovement.java index 331b91920..07810bfe3 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityMovement.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/type/AbilityMovement.java @@ -226,7 +226,7 @@ public abstract class AbilityMovement extends Ability { * By sending S12 manually here — before friction — the client receives the full * velocity and moves at the correct speed. */ - private void sendPlayerVelocity(EntityLivingBase caster) { + protected void sendPlayerVelocity(EntityLivingBase caster) { if (caster instanceof EntityPlayerMP) { ((EntityPlayerMP) caster).playerNetServerHandler.sendPacket( new S12PacketEntityVelocity(caster)); @@ -234,11 +234,11 @@ public abstract class AbilityMovement extends Ability { } /** - * Apply movement velocity to the caster (horizontal only, Y unchanged). + * Apply horizontal momentum and sync to client. */ - protected void applyVelocity(EntityLivingBase caster, float speed) { - caster.motionX = movementDirection.xCoord * speed; - caster.motionZ = movementDirection.zCoord * speed; + protected void applyHorizontalMomentum(EntityLivingBase caster, double motionX, double motionZ) { + caster.motionX = motionX; + caster.motionZ = motionZ; if (!isPreview()) { if (caster instanceof EntityPlayerMP) { sendPlayerVelocity(caster); @@ -248,36 +248,27 @@ public abstract class AbilityMovement extends Ability { } } + /** + * Apply movement velocity to the caster (horizontal only, Y unchanged). + */ + protected void applyVelocity(EntityLivingBase caster, float speed) { + applyHorizontalMomentum(caster, movementDirection.xCoord * speed, movementDirection.zCoord * speed); + } + /** * Apply movement velocity with zero vertical motion. * Used for ground-locked movement like Charge. */ protected void applyVelocityFlat(EntityLivingBase caster, float speed) { - caster.motionX = movementDirection.xCoord * speed; + applyHorizontalMomentum(caster, movementDirection.xCoord * speed, movementDirection.zCoord * speed); caster.motionY = 0; - caster.motionZ = movementDirection.zCoord * speed; - if (!isPreview()) { - if (caster instanceof EntityPlayerMP) { - sendPlayerVelocity(caster); - } else { - caster.velocityChanged = true; - } - } } /** * Stop all horizontal momentum. */ protected void stopMomentum(EntityLivingBase caster) { - caster.motionX = 0; - caster.motionZ = 0; - if (!isPreview()) { - if (caster instanceof EntityPlayerMP) { - sendPlayerVelocity(caster); - } else { - caster.velocityChanged = true; - } - } + applyHorizontalMomentum(caster, 0, 0); } // ═══════════════════════════════════════════════════════════════════ diff --git a/src/main/java/kamkeel/npcs/network/packets/data/ability/PlayerAbilityStatePacket.java b/src/main/java/kamkeel/npcs/network/packets/data/ability/PlayerAbilityStatePacket.java index 256a38299..5d62b1bcf 100644 --- a/src/main/java/kamkeel/npcs/network/packets/data/ability/PlayerAbilityStatePacket.java +++ b/src/main/java/kamkeel/npcs/network/packets/data/ability/PlayerAbilityStatePacket.java @@ -22,6 +22,7 @@ import java.io.IOException; * bit 1: rotation locked (freeze yaw/pitch) * bit 2: has ability movement (ability controls motion — suppress WASD but don't zero) * bit 3: position locked (freeze position) + * bit 5: ability is in ACTIVE phase */ public final class PlayerAbilityStatePacket extends AbstractPacket { public static final String packetName = "Data|PlayerAbilityState"; @@ -31,6 +32,7 @@ public final class PlayerAbilityStatePacket extends AbstractPacket { public static final byte FLAG_HAS_ABILITY_MOVEMENT = 4; public static final byte FLAG_POSITION_LOCKED = 8; public static final byte FLAG_WAS_FLYING_AT_LOCK = 16; + public static final byte FLAG_ACTIVE_PHASE = 32; private byte flags; private float lockedYaw; diff --git a/src/main/java/kamkeel/npcs/util/FileNameHelper.java b/src/main/java/kamkeel/npcs/util/FileNameHelper.java new file mode 100644 index 000000000..6248600fb --- /dev/null +++ b/src/main/java/kamkeel/npcs/util/FileNameHelper.java @@ -0,0 +1,97 @@ +package kamkeel.npcs.util; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Utility helpers for filename-safe ability/chained names. + * Allowed characters are letters, digits, underscore, and dash. + */ +public final class FileNameHelper { + + private static final Set WINDOWS_RESERVED_NAMES = new HashSet(Arrays.asList( + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + )); + + private FileNameHelper() { + } + + /** + * Sanitize a name to a filename-safe identifier. + * Whitespace becomes underscores; unsupported chars are removed. + */ + public static String sanitizeName(String input, String fallback) { + String sanitized = sanitizeInternal(input); + if (sanitized.isEmpty()) { + sanitized = sanitizeInternal(fallback); + } + if (sanitized.isEmpty()) { + sanitized = "Ability"; + } + if (isReservedWindowsName(sanitized)) { + sanitized = "_" + sanitized; + } + return sanitized; + } + + /** + * Sanitize textfield input without forcing a fallback. + */ + public static String sanitizeTextInput(String input) { + return sanitizeInternal(input); + } + + /** + * Quick check for compatibility with sanitized identifier rules. + */ + public static boolean isFileNameCompatible(String value) { + if (value == null || value.isEmpty()) return false; + if (isReservedWindowsName(value)) return false; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!Character.isLetterOrDigit(c) && c != '_' && c != '-') { + return false; + } + } + return true; + } + + /** + * Human-readable fallback display text for sanitized names. + */ + public static String toDisplayName(String value) { + if (value == null || value.isEmpty()) return ""; + return value.replace('_', ' '); + } + + private static String sanitizeInternal(String raw) { + if (raw == null) return ""; + String trimmed = raw.trim(); + if (trimmed.isEmpty()) return ""; + + StringBuilder out = new StringBuilder(trimmed.length()); + + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (Character.isLetterOrDigit(c)) { + out.append(c); + } else if (c == '_' || c == '-') { + out.append(c); + } else if (Character.isWhitespace(c)) { + if (out.length() == 0) continue; + char last = out.charAt(out.length() - 1); + if (last == '_') continue; + out.append('_'); + } + } + + return out.toString(); + } + + private static boolean isReservedWindowsName(String value) { + return WINDOWS_RESERVED_NAMES.contains(value.toUpperCase()); + } +} diff --git a/src/main/java/noppes/npcs/AbstractDataAbilities.java b/src/main/java/noppes/npcs/AbstractDataAbilities.java index a2b94fdc9..92103a102 100644 --- a/src/main/java/noppes/npcs/AbstractDataAbilities.java +++ b/src/main/java/noppes/npcs/AbstractDataAbilities.java @@ -268,6 +268,15 @@ public abstract class AbstractDataAbilities { return currentAbility != null && currentAbility.isExecuting(); } + /** + * True when an executing ability grants invulnerability for its current phase. + */ + public boolean isCurrentAbilityInvulnerable() { + return currentAbility != null + && currentAbility.isExecuting() + && currentAbility.isInvulnerableForCurrentPhase(); + } + /** * Get the currently executing ability. */ diff --git a/src/main/java/noppes/npcs/DataAbilities.java b/src/main/java/noppes/npcs/DataAbilities.java index 68d1231d7..a21eace79 100644 --- a/src/main/java/noppes/npcs/DataAbilities.java +++ b/src/main/java/noppes/npcs/DataAbilities.java @@ -555,12 +555,15 @@ public class DataAbilities extends AbstractDataAbilities { * @return true if ability was interrupted */ public boolean onDamage(DamageSource source, float amount) { - // Track hit for hit count condition - recordHit(); - if (currentAbility == null || !currentAbility.isExecuting()) { return false; } + if (currentAbility.isInvulnerableForCurrentPhase()) { + return false; + } + + // Track hit for hit count condition + recordHit(); net.minecraft.entity.Entity sourceEntity = source.getEntity(); EntityLivingBase attacker = sourceEntity instanceof EntityLivingBase ? (EntityLivingBase) sourceEntity : null; diff --git a/src/main/java/noppes/npcs/ScriptPlayerEventHandler.java b/src/main/java/noppes/npcs/ScriptPlayerEventHandler.java index 2cadbfd9f..5b5efccbe 100644 --- a/src/main/java/noppes/npcs/ScriptPlayerEventHandler.java +++ b/src/main/java/noppes/npcs/ScriptPlayerEventHandler.java @@ -629,7 +629,12 @@ public class ScriptPlayerEventHandler { // Handle ability damage interactions (Guard counter, damage reduction) PlayerData pData = PlayerData.get((EntityPlayer) event.entityLiving); if (pData != null && pData.abilityData != null && pData.abilityData.isExecutingAbility()) { - event.ammount = pData.abilityData.onDamage(event.source, event.ammount); + if (pData.abilityData.isCurrentAbilityInvulnerable()) { + event.ammount = 0; + cancel = true; + } else { + event.ammount = pData.abilityData.onDamage(event.source, event.ammount); + } } PlayerDataScript handler = ScriptController.Instance.getPlayerScripts((EntityPlayer) event.entityLiving); diff --git a/src/main/java/noppes/npcs/client/ClientAbilityState.java b/src/main/java/noppes/npcs/client/ClientAbilityState.java index c737024fd..fb3d88c6e 100644 --- a/src/main/java/noppes/npcs/client/ClientAbilityState.java +++ b/src/main/java/noppes/npcs/client/ClientAbilityState.java @@ -16,6 +16,7 @@ public class ClientAbilityState { public static boolean hasAbilityMovement = false; public static boolean positionLocked = false; public static boolean wasFlyingAtLock = false; + public static boolean activePhase = false; public static float lockedYaw = 0; public static float lockedPitch = 0; @@ -28,6 +29,7 @@ public class ClientAbilityState { hasAbilityMovement = (flags & PlayerAbilityStatePacket.FLAG_HAS_ABILITY_MOVEMENT) != 0; positionLocked = (flags & PlayerAbilityStatePacket.FLAG_POSITION_LOCKED) != 0; wasFlyingAtLock = (flags & PlayerAbilityStatePacket.FLAG_WAS_FLYING_AT_LOCK) != 0; + activePhase = (flags & PlayerAbilityStatePacket.FLAG_ACTIVE_PHASE) != 0; lockedYaw = yaw; lockedPitch = pitch; } @@ -56,6 +58,7 @@ public class ClientAbilityState { hasAbilityMovement = false; positionLocked = false; wasFlyingAtLock = false; + activePhase = false; lockedYaw = 0; lockedPitch = 0; } diff --git a/src/main/java/noppes/npcs/client/ClientTickHandler.java b/src/main/java/noppes/npcs/client/ClientTickHandler.java index 3a9416563..dbe6e691d 100644 --- a/src/main/java/noppes/npcs/client/ClientTickHandler.java +++ b/src/main/java/noppes/npcs/client/ClientTickHandler.java @@ -54,6 +54,7 @@ public class ClientTickHandler { private long buttonTime = 0L; private final int[] ignoreKeys = new int[]{157, 29, 54, 42, 184, 56, 220, 219}; private boolean lastSpecialKeyDown = false; + private boolean wasMovementSuppressed = false; @SubscribeEvent(priority = EventPriority.LOWEST) public void onClientTick(TickEvent.ClientTickEvent event) { @@ -84,9 +85,15 @@ public class ClientTickHandler { lastSpecialKeyDown = specialKeyDown; } + boolean suppressMovement = mc.currentScreen == null && ClientAbilityState.shouldSuppressMovementInput(); + if (!suppressMovement && wasMovementSuppressed) { + syncMovementKeyStates(mc); + } + wasMovementSuppressed = suppressMovement; + // Suppress player input during ability-controlled phases. // Only suppress when no GUI screen is open (screens already capture input). - if (mc.currentScreen == null && ClientAbilityState.shouldSuppressMovementInput()) { + if (suppressMovement) { // Unpress movement keybinds at the source BEFORE updatePlayerMoveState() reads them. KeyBinding.setKeyBindState(mc.gameSettings.keyBindForward.getKeyCode(), false); KeyBinding.setKeyBindState(mc.gameSettings.keyBindBack.getKeyCode(), false); @@ -301,6 +308,27 @@ public class ClientTickHandler { || keyCode == mc.gameSettings.keyBindSprint.getKeyCode(); } + private void syncMovementKeyStates(Minecraft mc) { + syncKeyBindingState(mc.gameSettings.keyBindForward); + syncKeyBindingState(mc.gameSettings.keyBindBack); + syncKeyBindingState(mc.gameSettings.keyBindLeft); + syncKeyBindingState(mc.gameSettings.keyBindRight); + syncKeyBindingState(mc.gameSettings.keyBindJump); + syncKeyBindingState(mc.gameSettings.keyBindSneak); + syncKeyBindingState(mc.gameSettings.keyBindSprint); + } + + private void syncKeyBindingState(KeyBinding keyBinding) { + int keyCode = keyBinding.getKeyCode(); + boolean pressed; + if (keyCode < 0) { + pressed = Mouse.isButtonDown(keyCode + 100); + } else { + pressed = Keyboard.isKeyDown(keyCode); + } + KeyBinding.setKeyBindState(keyCode, pressed); + } + private boolean isIgnoredKey(int key) { int[] var2 = this.ignoreKeys; int var3 = var2.length; diff --git a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilitySaveConfirm.java b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilitySaveConfirm.java index 57a363e1f..a70b0318d 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilitySaveConfirm.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilitySaveConfirm.java @@ -84,8 +84,12 @@ public class SubGuiAbilitySaveConfirm extends SubGuiInterface implements ISubGui @Override public void subGuiClosed(SubGuiInterface subgui) { if (subgui instanceof SubGuiDuplicateNameConfirm) { - if (((SubGuiDuplicateNameConfirm) subgui).isConfirmed()) { + SubGuiDuplicateNameConfirm confirm = (SubGuiDuplicateNameConfirm) subgui; + if (confirm.isConfirmed()) { doSave(); + } else if (confirm.isBack()) { + // Keep this save-confirm dialog open. + return; } else { close(); } diff --git a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiDuplicateNameConfirm.java b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiDuplicateNameConfirm.java index b16386c32..5d316f416 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiDuplicateNameConfirm.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiDuplicateNameConfirm.java @@ -10,7 +10,13 @@ import noppes.npcs.client.gui.util.SubGuiInterface; */ public class SubGuiDuplicateNameConfirm extends SubGuiInterface { - private boolean confirmed = false; + public enum Result { + CONTINUE, + BACK, + CANCEL + } + + private Result result = Result.CANCEL; public SubGuiDuplicateNameConfirm() { setBackground("menubg.png"); @@ -26,22 +32,38 @@ public class SubGuiDuplicateNameConfirm extends SubGuiInterface { addLabel(new GuiNpcLabel(0, "ability.duplicate.name", guiLeft + 10, y)); y += 30; - addButton(new GuiNpcButton(0, guiLeft + 20, y, 80, 20, "gui.continue")); - addButton(new GuiNpcButton(1, guiLeft + 120, y, 80, 20, "gui.cancel")); + addButton(new GuiNpcButton(0, guiLeft + 10, y, 65, 20, "gui.continue")); + addButton(new GuiNpcButton(1, guiLeft + 78, y, 65, 20, "gui.back")); + addButton(new GuiNpcButton(2, guiLeft + 146, y, 65, 20, "gui.cancel")); } @Override public void buttonEvent(GuiButton guibutton) { if (guibutton.id == 0) { - confirmed = true; + result = Result.CONTINUE; close(); } else if (guibutton.id == 1) { - confirmed = false; + result = Result.BACK; + close(); + } else if (guibutton.id == 2) { + result = Result.CANCEL; close(); } } + public Result getResult() { + return result; + } + public boolean isConfirmed() { - return confirmed; + return result == Result.CONTINUE; + } + + public boolean isBack() { + return result == Result.BACK; + } + + public boolean isCancelled() { + return result == Result.CANCEL; } } diff --git a/src/main/java/noppes/npcs/client/gui/builder/GuiFieldBuilder.java b/src/main/java/noppes/npcs/client/gui/builder/GuiFieldBuilder.java index 8d7d88d61..64b81624d 100644 --- a/src/main/java/noppes/npcs/client/gui/builder/GuiFieldBuilder.java +++ b/src/main/java/noppes/npcs/client/gui/builder/GuiFieldBuilder.java @@ -245,6 +245,9 @@ public class GuiFieldBuilder { case STRING: { String sVal = def.getValue() != null ? def.getValue().toString() : ""; GuiNpcTextField tf = new GuiNpcTextField(widgetId, parent, fontRenderer, fieldX, y, fieldW, 20, sVal); + if ("gui.name".equals(def.getLabel())) { + tf.setFileNameSafe(); + } if (!def.isEnabled()) tf.setEnabled(false); if (hover != null) tf.setHoverText(hover); sw.addTextField(tf); diff --git a/src/main/java/noppes/npcs/client/gui/global/GuiNpcManageAbilities.java b/src/main/java/noppes/npcs/client/gui/global/GuiNpcManageAbilities.java index 95cce7cf9..64be9e499 100644 --- a/src/main/java/noppes/npcs/client/gui/global/GuiNpcManageAbilities.java +++ b/src/main/java/noppes/npcs/client/gui/global/GuiNpcManageAbilities.java @@ -104,7 +104,6 @@ public class GuiNpcManageAbilities extends GuiAbilityInterface // ==================== PENDING SAVE ==================== private Ability pendingSaveAbility = null; - private boolean pendingNewCreation = false; // ==================== PENDING VARIANT ==================== private String pendingTypeId = null; @@ -511,13 +510,6 @@ public class GuiNpcManageAbilities extends GuiAbilityInterface Ability newAbility = AbilityController.Instance.create(pendingTypeId); if (newAbility != null) { gui.getVariants().get(idx).apply(newAbility); - if (hasDuplicateName(newAbility)) { - pendingSaveAbility = newAbility; - pendingNewCreation = true; - pendingTypeId = null; - setSubGui(new SubGuiDuplicateNameConfirm()); - return true; - } pendingTypeId = null; openConfig(newAbility); return true; @@ -544,12 +536,6 @@ public class GuiNpcManageAbilities extends GuiAbilityInterface if (variants.size() == 1) { variants.get(0).apply(newAbility); } - if (hasDuplicateName(newAbility)) { - pendingSaveAbility = newAbility; - pendingNewCreation = true; - setSubGui(new SubGuiDuplicateNameConfirm()); - return true; - } openConfig(newAbility); return true; } @@ -561,27 +547,22 @@ public class GuiNpcManageAbilities extends GuiAbilityInterface * @return true if a SubGui was opened (caller should return early, skipping initGui) */ private boolean handleDuplicateNameClosed(SubGuiDuplicateNameConfirm gui) { - if (gui.isConfirmed() && pendingSaveAbility != null) { - if (pendingNewCreation) { - openConfig(pendingSaveAbility); - pendingSaveAbility = null; - pendingNewCreation = false; - return true; - } else { - PacketClient.sendClient(new CustomAbilitySavePacket(pendingSaveAbility.writeNBT())); - pendingSaveAbility = null; - } - } else if (pendingSaveAbility != null) { - if (pendingNewCreation) { - pendingSaveAbility = null; - pendingNewCreation = false; - } else { - Ability ability = pendingSaveAbility; - pendingSaveAbility = null; - setSubGui(ability.createConfigGui(this)); - return true; - } + if (pendingSaveAbility == null) return false; + + if (gui.isConfirmed()) { + PacketClient.sendClient(new CustomAbilitySavePacket(pendingSaveAbility.writeNBT())); + pendingSaveAbility = null; + return false; } + + if (gui.isBack()) { + Ability ability = pendingSaveAbility; + pendingSaveAbility = null; + setSubGui(ability.createConfigGui(this)); + return true; + } + + pendingSaveAbility = null; return false; } @@ -590,7 +571,6 @@ public class GuiNpcManageAbilities extends GuiAbilityInterface */ private boolean handlePendingSave() { if (hasDuplicateName(pendingSaveAbility)) { - pendingNewCreation = false; setSubGui(new SubGuiDuplicateNameConfirm()); return true; } diff --git a/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityHotbarComponent.java b/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityHotbarComponent.java index 3bcf1a3c3..eaadbbc7b 100644 --- a/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityHotbarComponent.java +++ b/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityHotbarComponent.java @@ -9,6 +9,7 @@ import kamkeel.npcs.network.PacketHandler; import kamkeel.npcs.network.packets.player.ability.AbilityHotbarSelectPacket; import net.minecraft.client.Minecraft; import noppes.npcs.client.ClientProxy; +import noppes.npcs.client.ClientAbilityState; import net.minecraft.client.gui.FontRenderer; import net.minecraft.client.gui.GuiButton; import net.minecraft.client.gui.ScaledResolution; @@ -189,6 +190,7 @@ public class AbilityHotbarComponent extends HudComponent { int centerX = overlayWidth / 2; int centerY = overlayHeight / 2; + boolean activePhase = ClientAbilityState.activePhase; // Compute text visibility: 0=Shown, 1=Hidden, 2=Held boolean showText; @@ -246,16 +248,16 @@ public class AbilityHotbarComponent extends HudComponent { if (rawSlotIndex == -1) { // Deselect slot — draw empty marker - drawDeselectSlot(cx, cy, scaledSize, isCenter, slotAlpha); + drawDeselectSlot(cx, cy, scaledSize, isCenter, slotAlpha, activePhase); } else { - slots[rawSlotIndex].drawCarousel(mc, sr, slotCooldown, cx, cy, scaledSize, isCenter, slotAlpha, showText); + slots[rawSlotIndex].drawCarousel(mc, sr, slotCooldown, cx, cy, scaledSize, isCenter, slotAlpha, showText, activePhase); } } GL11.glPopMatrix(); } - private void drawDeselectSlot(int cx, int cy, int size, boolean isCenter, float alpha) { + private void drawDeselectSlot(int cx, int cy, int size, boolean isCenter, float alpha, boolean activePhase) { float radius = size / 2f; float baseAlpha = isCenter ? 0.9f : 0.6f; float a = baseAlpha * alpha; @@ -268,7 +270,13 @@ public class AbilityHotbarComponent extends HudComponent { GL11.glEnable(GL11.GL_BLEND); GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); - if (isCenter) { + if (activePhase) { + if (isCenter) { + GL11.glColor4f(0.65f, 0.14f, 0.14f, a); + } else { + GL11.glColor4f(0.38f, 0.10f, 0.10f, a); + } + } else if (isCenter) { GL11.glColor4f(0.5f, 0.2f, 0.2f, a); } else { GL11.glColor4f(0.3f, 0.3f, 0.3f, a); @@ -292,7 +300,13 @@ public class AbilityHotbarComponent extends HudComponent { } // Draw X mark - if (isCenter) { + if (activePhase) { + if (isCenter) { + GL11.glColor4f(1.0f, 0.45f, 0.35f, alpha); + } else { + GL11.glColor4f(0.75f, 0.30f, 0.30f, alpha * 0.8f); + } + } else if (isCenter) { GL11.glColor4f(0.8f, 0.4f, 0.4f, alpha); } else { GL11.glColor4f(0.5f, 0.5f, 0.5f, alpha * 0.8f); diff --git a/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityHotbarSlotRenderer.java b/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityHotbarSlotRenderer.java index 44d81c441..ab55c9560 100644 --- a/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityHotbarSlotRenderer.java +++ b/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityHotbarSlotRenderer.java @@ -48,7 +48,7 @@ public class AbilityHotbarSlotRenderer extends Gui { public void drawCarousel(Minecraft mc, ScaledResolution sr, float cooldownProgress, int cx, int cy, int size, boolean isCenter, float slotAlpha, - boolean showText) { + boolean showText, boolean activePhase) { float nameAlpha = this.nameAlpha; boolean isHorizontal = ConfigClient.AbilityHotbarHorizontal; if (size <= 0 || slotAlpha <= 0) return; @@ -61,21 +61,54 @@ public class AbilityHotbarSlotRenderer extends Gui { boolean altTexture = ConfigClient.AbilityHotbarAltTexture; - if (altTexture) { - drawRoundedRect(-radius, -radius, radius, radius, 0.6f, 0.6f, 0.6f, 0.75f * alpha, false); + float fillR; + float fillG; + float fillB; + float outlineR; + float outlineG; + float outlineB; + + if (activePhase) { if (isCenter) { - drawRoundedRect(-radius, -radius, radius, radius, 0.8f, 0.8f, 0.2f, alpha, true); + fillR = 0.95f; + fillG = 0.26f; + fillB = 0.22f; + outlineR = 1.0f; + outlineG = 0.52f; + outlineB = 0.28f; } else { - drawRoundedRect(-radius, -radius, radius, radius, 0.4f, 0.4f, 0.4f, alpha * 0.8f, true); + fillR = 0.36f; + fillG = 0.14f; + fillB = 0.14f; + outlineR = 0.65f; + outlineG = 0.22f; + outlineB = 0.20f; } + } else if (isCenter) { + fillR = 0.8f; + fillG = 0.8f; + fillB = 0.2f; + outlineR = 0.8f; + outlineG = 0.8f; + outlineB = 0.2f; } else { - if (isCenter) { - drawCircle(radius, 0.8f, 0.8f, 0.2f, 0.66f * alpha, false); - drawCircle(radius, 0.8f, 0.8f, 0.2f, alpha, true); - } else { - drawCircle(radius, 1f, 1f, 1f, 0.66f * alpha, false); - drawCircle(radius, 0.4f, 0.4f, 0.4f, alpha * 0.8f, true); - } + fillR = 0.4f; + fillG = 0.4f; + fillB = 0.4f; + outlineR = 1f; + outlineG = 1f; + outlineB = 1f; + } + + if (altTexture) { + float baseR = activePhase ? 0.30f : 0.6f; + float baseG = activePhase ? 0.12f : 0.6f; + float baseB = activePhase ? 0.12f : 0.6f; + drawRoundedRect(-radius, -radius, radius, radius, baseR, baseG, baseB, 0.75f * alpha, false); + drawRoundedRect(-radius, -radius, radius, radius, fillR, fillG, fillB, alpha * 0.8f, true); + } else { + drawCircle(radius, outlineR, outlineG, outlineB, 0.66f * alpha, false); + drawCircle(radius, fillR, fillG, fillB, alpha * 0.8f, true); } // Ability icon diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiNpcTextField.java b/src/main/java/noppes/npcs/client/gui/util/GuiNpcTextField.java index ffe6718ee..1d82deb61 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiNpcTextField.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiNpcTextField.java @@ -1,6 +1,7 @@ package noppes.npcs.client.gui.util; import kamkeel.npcs.util.TextSplitter; +import kamkeel.npcs.util.FileNameHelper; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.FontRenderer; import net.minecraft.client.gui.GuiScreen; @@ -26,6 +27,7 @@ public class GuiNpcTextField extends GuiTextField { public float minFloat = 0, maxFloat = Float.MAX_VALUE, defFloat = 0; protected static GuiNpcTextField activeTextfield = null; public boolean canEdit = true; + private boolean fileNameSafe = false; public String hoverableText = ""; private boolean wasHovered = false; private int hoverCount = 0; @@ -71,6 +73,11 @@ public class GuiNpcTextField extends GuiTextField { public boolean textboxKeyTyped(char c, int i) { if (!canEdit || !isFocused()) return false; + + if (fileNameSafe && !GuiScreen.isCtrlKeyDown() && !isFileNameCharAllowed(c, i)) { + return false; + } + // Allow Ctrl shortcuts (select all, copy, paste, cut) to bypass char filter if (GuiScreen.isCtrlKeyDown()) { // For Ctrl+V (paste) on numeric fields, filter clipboard to valid chars @@ -87,7 +94,17 @@ public class GuiNpcTextField extends GuiTextField { } return true; } - return super.textboxKeyTyped(c, i); + if (i == 47 && fileNameSafe) { + String clipboard = GuiScreen.getClipboardString(); + if (clipboard != null) { + String filtered = filterFileNameClipboard(clipboard); + if (!filtered.isEmpty()) + writeText(filtered); + } + return true; + } + boolean handled = super.textboxKeyTyped(c, i); + return handled; } if (!charAllowed(c, i)) return false; @@ -179,6 +196,9 @@ public class GuiNpcTextField extends GuiTextField { else if (getFloat() > maxFloat) setText(maxFloat + ""); } + if (fileNameSafe) { + normalizeFileNameText(); + } setCursorPositionZero(); if (listener != null) listener.unFocused(this); @@ -238,6 +258,51 @@ public class GuiNpcTextField extends GuiTextField { return this; } + public GuiNpcTextField setFileNameSafe() { + fileNameSafe = true; + normalizeFileNameText(); + return this; + } + + private boolean isSpecialNavigationKey(int keyCode) { + for (int allowed : allowedSpecialChars) { + if (allowed == keyCode) { + return true; + } + } + return false; + } + + private boolean isFileNameCharAllowed(char c, int keyCode) { + if (isSpecialNavigationKey(keyCode) || c < 32) { + return true; + } + return Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == ' '; + } + + private String filterFileNameClipboard(String clipboard) { + StringBuilder filtered = new StringBuilder(clipboard.length()); + for (int idx = 0; idx < clipboard.length(); idx++) { + char c = clipboard.charAt(idx); + if (Character.isLetterOrDigit(c) || c == '_' || c == '-') { + filtered.append(c); + } else if (Character.isWhitespace(c)) { + filtered.append(' '); + } + } + return filtered.toString(); + } + + private void normalizeFileNameText() { + String current = getText(); + String sanitized = FileNameHelper.sanitizeTextInput(current); + if (sanitized.equals(current)) return; + + int cursor = getCursorPosition(); + setText(sanitized); + setCursorPosition(Math.min(cursor, sanitized.length())); + } + public void setHoverText(String text) { this.hoverableText = StatCollector.translateToLocal(text); } diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java index 18a58783a..fa439578e 100644 --- a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java +++ b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java @@ -806,6 +806,9 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA if (currentAbility == null || !currentAbility.isExecuting()) { return amount; } + if (currentAbility.isInvulnerableForCurrentPhase()) { + return 0; + } net.minecraft.entity.Entity sourceEntity = source.getEntity(); EntityLivingBase attacker = sourceEntity instanceof EntityLivingBase ? (EntityLivingBase) sourceEntity : null; @@ -937,6 +940,9 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA if (wasFlyingAtLock) { flags |= PlayerAbilityStatePacket.FLAG_WAS_FLYING_AT_LOCK; } + if (currentAbility.getPhase() == AbilityPhase.ACTIVE) { + flags |= PlayerAbilityStatePacket.FLAG_ACTIVE_PHASE; + } return flags; } diff --git a/src/main/java/noppes/npcs/entity/EntityNPCInterface.java b/src/main/java/noppes/npcs/entity/EntityNPCInterface.java index 5c759d59c..d6ae75a6f 100644 --- a/src/main/java/noppes/npcs/entity/EntityNPCInterface.java +++ b/src/main/java/noppes/npcs/entity/EntityNPCInterface.java @@ -659,6 +659,9 @@ public abstract class EntityNPCInterface extends EntityCreature implements IEnti if (damagesource.damageType != null && damagesource.damageType.equals("outOfWorld") && isKilled()) { reset(); } + if (this.abilities != null && this.abilities.isCurrentAbilityInvulnerable()) { + return false; + } // Check for custom weapon attack speed - bypass immunity if enough time has passed Entity sourceEntity = damagesource.getEntity(); diff --git a/src/main/resources/assets/customnpcs/lang/en_US.lang b/src/main/resources/assets/customnpcs/lang/en_US.lang index 0a7b1ecdc..90d877011 100644 --- a/src/main/resources/assets/customnpcs/lang/en_US.lang +++ b/src/main/resources/assets/customnpcs/lang/en_US.lang @@ -167,6 +167,7 @@ gui.unused=Unused gui.apply=Apply gui.cancel=Cancel gui.continue=Continue +gui.back=Back gui.yes=Yes gui.abilities=Abilities gui.toggles=Toggles @@ -1933,6 +1934,12 @@ ability.hover.burstReplay=Replay windup and active animations for each burst ite ability.save.confirm=Save ability preset ability.save.overwrite=Overwrite existing preset ability.duplicate.name=A preset with this name already exists +ability.invulnerable=Invulnerable +ability.invulnerable.none=None +ability.invulnerable.windup=Windup +ability.invulnerable.active=Active +ability.invulnerable.both=Both +ability.hover.invulnerable=Ignores all incoming damage and hit reactions during selected phases. Windup/Both also disable interrupts. ability.load.select=Select ability to load: ability.load.mode=How should this ability be loaded? ability.clone=Clone