diff --git a/src/api b/src/api index ae98d12fa..e504fc4b4 160000 --- a/src/api +++ b/src/api @@ -1 +1 @@ -Subproject commit ae98d12fafa29f48656f8bc9d2ad998a7fd2c3d3 +Subproject commit e504fc4b4acd97e97e5c3f5b0850498d7b1fdd95 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 ceba080f3..edf222844 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java @@ -830,6 +830,15 @@ public abstract class Ability implements IAbility, IAbilityAction { addIconLayerFields(defs, icon, 0, isCustom); addIconLayerFields(defs, icon, 1, () -> isCustom.getAsBoolean() && icon.getLayerCount() >= 2); addIconLayerFields(defs, icon, 2, () -> isCustom.getAsBoolean() && icon.getLayerCount() >= 3); + + // Animation + defs.add(FieldDef.section("Animation").tab("Effects").visibleWhen(isCustom)); + defs.add(FieldDef.boolField("gui.animated", icon::isAnimated, icon::setAnimated) + .tab("Effects").visibleWhen(isCustom).hover("gui.animated.hover")); + defs.add(FieldDef.intField("gui.frameCount", icon::getFrameCount, icon::setFrameCount) + .tab("Effects").range(1, 256).visibleWhen(() -> isCustom.getAsBoolean() && icon.isAnimated())); + defs.add(FieldDef.intField("gui.frameTime", icon::getFrameTime, icon::setFrameTime) + .tab("Effects").range(1, 100).visibleWhen(() -> isCustom.getAsBoolean() && icon.isAnimated())); } // External field providers (e.g., DBC Addon injecting a "DBC" tab) diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/data/AbilityIconData.java b/src/main/java/kamkeel/npcs/controllers/data/ability/data/AbilityIconData.java index a6daa1044..8efb25791 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/data/AbilityIconData.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/data/AbilityIconData.java @@ -32,6 +32,11 @@ public class AbilityIconData { /** Per-state icon UV overrides for layer 0 (0-indexed, maps to toggle states 1, 2, ...). */ private int[][] stateIcons = null; + // Animation + private boolean animated = false; + private int frameCount = 1; + private int frametime = 2; + // ═══════════════════════════════════════════════════════════════════ // LAYER INNER CLASS // ═══════════════════════════════════════════════════════════════════ @@ -125,6 +130,10 @@ public class AbilityIconData { } nbt.setTag("Layers", layerList); + nbt.setBoolean("Animated", animated); + nbt.setInteger("FrameCount", frameCount); + nbt.setInteger("FrameTime", frametime); + if (stateIcons != null && stateIcons.length > 0) { NBTTagList list = new NBTTagList(); for (int[] pair : stateIcons) { @@ -150,6 +159,13 @@ public class AbilityIconData { if (layerCount < 1) layerCount = 1; if (layerCount > MAX_LAYERS) layerCount = MAX_LAYERS; + if (nbt.hasKey("Animated")) + animated = nbt.getBoolean("Animated"); + if (nbt.hasKey("FrameCount")) + frameCount = Math.max(1, nbt.getInteger("FrameCount")); + if (nbt.hasKey("FrameTime")) + frametime = Math.max(1, nbt.getInteger("FrameTime")); + if (nbt.hasKey("Layers")) { NBTTagList layerList = nbt.getTagList("Layers", 10); for (int i = 0; i < MAX_LAYERS; i++) { @@ -256,6 +272,37 @@ public class AbilityIconData { save(); } + // ═══════════════════════════════════════════════════════════════════ + // ANIMATION + // ═══════════════════════════════════════════════════════════════════ + + public boolean isAnimated() { + return animated; + } + + public void setAnimated(boolean animated) { + this.animated = animated; + save(); + } + + public int getFrameCount() { + return frameCount; + } + + public void setFrameCount(int frameCount) { + this.frameCount = Math.max(1, frameCount); + save(); + } + + public int getFrameTime() { + return frametime; + } + + public void setFrameTime(int frametime) { + this.frametime = Math.max(1, frametime); + save(); + } + // ═══════════════════════════════════════════════════════════════════ // PER-STATE ICON OVERRIDES (applies to layer 0) // ═══════════════════════════════════════════════════════════════════ diff --git a/src/main/java/noppes/npcs/client/gui/SubGuiEffectGeneral.java b/src/main/java/noppes/npcs/client/gui/SubGuiEffectGeneral.java index 0b21d028d..ce8aa221d 100644 --- a/src/main/java/noppes/npcs/client/gui/SubGuiEffectGeneral.java +++ b/src/main/java/noppes/npcs/client/gui/SubGuiEffectGeneral.java @@ -8,6 +8,7 @@ import net.minecraft.util.ResourceLocation; import noppes.npcs.client.ClientCacheHandler; import noppes.npcs.client.gui.global.GuiNPCManageEffects; import noppes.npcs.client.gui.script.GuiScriptInterface; +import noppes.npcs.client.renderer.AnimationHelper; import noppes.npcs.client.gui.util.GuiMenuTopButton; import noppes.npcs.client.gui.util.GuiNpcButtonYesNo; import noppes.npcs.client.gui.util.GuiNpcLabel; @@ -129,6 +130,12 @@ public class SubGuiEffectGeneral extends SubGuiInterface implements ITextfieldLi int width = data.getTotalWidth(); int height = data.getTotalHeight(); + // Animation V offset for preview + if (data.isAnimated()) { + iconY += (int) (data.getCurrentFrameVOffset() * height); + } else if (effect.animated && effect.frameCount > 1) { + iconY += (int) (AnimationHelper.getFrameVOffset(height, effect.frameCount, effect.frametime) * height); + } func_152125_a(x, y, iconX, iconY, iconWidth, iconHeight, iconRenderSize, iconRenderSize, width, height); @@ -183,6 +190,33 @@ public class SubGuiEffectGeneral extends SubGuiInterface implements ITextfieldLi effect.height )); scrollWindow.addLabel(new GuiNpcLabel(9, "effect.editor.height", scrollWindow.getTextField(9).xPosition - 43, y + 6, 0xFFFFFF)); + y += 25; + + // Animation controls + scrollWindow.addLabel(new GuiNpcLabel(11, "gui.animated", x, y + 5, 0xFFFFFF)); + GuiNpcButtonYesNo animBtn = new GuiNpcButtonYesNo(11, x + 70, y, 50, 20, effect.animated); + animBtn.setHoverText("gui.animated.hover"); + scrollWindow.addButton(animBtn); + + if (effect.animated) { + y += 23; + scrollWindow.addLabel(new GuiNpcLabel(12, "gui.frameCount", x, y + 6, 0xFFFFFF)); + scrollWindow.addTextField(setIntegerOnly( + new GuiNpcTextField(12, this, x + 80, y, 60, 20, "" + effect.frameCount), + 1, + 256, + effect.frameCount + )); + + y += 23; + scrollWindow.addLabel(new GuiNpcLabel(13, "gui.frameTime", x, y + 6, 0xFFFFFF)); + scrollWindow.addTextField(setIntegerOnly( + new GuiNpcTextField(13, this, x + 80, y, 60, 20, "" + effect.frametime), + 1, + 100, + effect.frametime + )); + } } @@ -209,6 +243,11 @@ public class SubGuiEffectGeneral extends SubGuiInterface implements ITextfieldLi GuiNpcButtonYesNo button = (GuiNpcButtonYesNo) guibutton; effect.lossOnDeath = button.getBoolean(); } + if (id == 11) { + GuiNpcButtonYesNo button = (GuiNpcButtonYesNo) guibutton; + effect.animated = button.getBoolean(); + initGui(); + } } @Override @@ -248,6 +287,12 @@ public class SubGuiEffectGeneral extends SubGuiInterface implements ITextfieldLi case 9: effect.height = guiNpcTextField.getInteger(); break; + case 12: + effect.frameCount = Math.max(1, guiNpcTextField.getInteger()); + break; + case 13: + effect.frametime = Math.max(1, guiNpcTextField.getInteger()); + break; } } 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 dff78a9a0..99af06e1c 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiChainedAbilityConfig.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiChainedAbilityConfig.java @@ -193,6 +193,14 @@ public class SubGuiChainedAbilityConfig extends SubGuiInterface implements IText .tab("Icon").visibleWhen(layerVisible)); } + // Animation + fieldDefs.add(FieldDef.section("Animation").tab("Icon")); + fieldDefs.add(FieldDef.boolField("gui.animated", chainIcon::isAnimated, chainIcon::setAnimated).tab("Icon").hover("gui.animated.hover")); + fieldDefs.add(FieldDef.intField("gui.frameCount", chainIcon::getFrameCount, chainIcon::setFrameCount) + .tab("Icon").range(1, 256).visibleWhen(chainIcon::isAnimated)); + fieldDefs.add(FieldDef.intField("gui.frameTime", chainIcon::getFrameTime, chainIcon::setFrameTime) + .tab("Icon").range(1, 100).visibleWhen(chainIcon::isAnimated)); + // External field providers (e.g., DBC Addon injecting a "DBC" tab) if (AbilityController.Instance != null) { for (IChainedAbilityFieldProvider provider : AbilityController.Instance.getChainedFieldProviders()) { diff --git a/src/main/java/noppes/npcs/client/gui/custom/components/CustomGuiTexturedRect.java b/src/main/java/noppes/npcs/client/gui/custom/components/CustomGuiTexturedRect.java index 58303db63..021323329 100644 --- a/src/main/java/noppes/npcs/client/gui/custom/components/CustomGuiTexturedRect.java +++ b/src/main/java/noppes/npcs/client/gui/custom/components/CustomGuiTexturedRect.java @@ -7,6 +7,7 @@ import noppes.npcs.api.gui.ICustomGuiComponent; import noppes.npcs.client.ClientCacheHandler; import noppes.npcs.client.gui.custom.GuiCustom; import noppes.npcs.client.gui.custom.interfaces.IGuiComponent; +import noppes.npcs.client.renderer.AnimationHelper; import noppes.npcs.client.renderer.ImageData; import noppes.npcs.scripted.gui.ScriptGuiTexturedRect; import org.lwjgl.opengl.GL11; @@ -30,6 +31,11 @@ public class CustomGuiTexturedRect extends Gui implements IGuiComponent { float alpha; float rotation; + // Animation + boolean animated; + int frameCount = 1; + int frametime = 2; + public CustomGuiTexturedRect(int id, String texture, int x, int y, int width, int height) { this(id, texture, x, y, width, height, 0, 0); } @@ -64,10 +70,18 @@ public class CustomGuiTexturedRect extends Gui implements IGuiComponent { int totalWidth = this.imageData.getTotalWidth(); int totalHeight = this.imageData.getTotalHeight(); + // Animation V offset + float vOff = 0f; + if (this.imageData.isAnimated()) { + vOff = this.imageData.getCurrentFrameVOffset(); + } else if (this.animated && this.frameCount > 1) { + vOff = AnimationHelper.getFrameVOffset(totalHeight, this.frameCount, this.frametime); + } + float u1 = (float) textureX / (float) totalWidth; float u2 = u1 + (float) width / (float) totalWidth; - float v1 = (float) textureY / (float) totalHeight; - float v2 = v1 + (float) height / (float) totalHeight; + float v1 = vOff + (float) textureY / (float) totalHeight; + float v2 = vOff + ((float) textureY + (float) height) / (float) totalHeight; GL11.glPushMatrix(); float red = (color >> 16 & 255) / 255f; @@ -104,6 +118,9 @@ public class CustomGuiTexturedRect extends Gui implements IGuiComponent { component.setColor(color); component.setAlpha(alpha); component.setRotation(rotation); + if (this.animated && this.frameCount > 1) { + component.setAnimation(this.frameCount, this.frametime); + } return component; } @@ -124,6 +141,12 @@ public class CustomGuiTexturedRect extends Gui implements IGuiComponent { rect.alpha = component.getAlpha(); rect.rotation = component.getRotation(); + if (component.isAnimated()) { + rect.animated = true; + rect.frameCount = component.getFrameCount(); + rect.frametime = component.getFrameTime(); + } + return rect; } } diff --git a/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityIcon.java b/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityIcon.java index d135eaaf0..2ed5f1512 100644 --- a/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityIcon.java +++ b/src/main/java/noppes/npcs/client/gui/hud/ability/AbilityIcon.java @@ -13,6 +13,7 @@ import net.minecraft.client.renderer.texture.TextureManager; import net.minecraft.nbt.NBTTagCompound; import net.minecraft.util.ResourceLocation; import noppes.npcs.client.ClientCacheHandler; +import noppes.npcs.client.renderer.AnimationHelper; import noppes.npcs.client.renderer.ImageData; import org.lwjgl.opengl.GL11; @@ -145,10 +146,18 @@ public class AbilityIcon extends Gui { int ix = (state > 0) ? data.getIconXForState(state) : layer.iconX; int iy = (state > 0) ? data.getIconYForState(state) : layer.iconY; + // Animation V offset + float vOff = 0f; + if (imageData.isAnimated()) { + vOff = imageData.getCurrentFrameVOffset(); + } else if (data.isAnimated()) { + vOff = AnimationHelper.getFrameVOffset((int) texH, data.getFrameCount(), data.getFrameTime()); + } + float u1 = ix / texW; - float v1 = iy / texH; + float v1 = vOff + iy / texH; float u2 = (ix + data.width) / texW; - float v2 = (iy + data.height) / texH; + float v2 = vOff + (iy + data.height) / texH; Tessellator t = Tessellator.instance; t.startDrawingQuads(); diff --git a/src/main/java/noppes/npcs/client/gui/item/SubGuiLinkedItem.java b/src/main/java/noppes/npcs/client/gui/item/SubGuiLinkedItem.java index b71f6642a..077c9fd9f 100644 --- a/src/main/java/noppes/npcs/client/gui/item/SubGuiLinkedItem.java +++ b/src/main/java/noppes/npcs/client/gui/item/SubGuiLinkedItem.java @@ -10,6 +10,7 @@ import net.minecraft.client.renderer.texture.TextureManager; import net.minecraft.util.ResourceLocation; import noppes.npcs.client.ClientCacheHandler; import noppes.npcs.client.CustomNpcResourceListener; +import noppes.npcs.client.renderer.AnimationHelper; import noppes.npcs.client.gui.SubGuiColorSelector; import noppes.npcs.client.gui.global.GuiNPCManageLinked; import noppes.npcs.client.gui.script.GuiScriptInterface; @@ -286,6 +287,31 @@ public class SubGuiLinkedItem extends SubGuiInterface implements ITextfieldListe scrollWindow.addLabel(new GuiNpcLabel(70, "display.durabilityShow", x, localY + 5, 0xFFFFFF)); GuiNpcButtonYesNo durabilityButton = new GuiNpcButtonYesNo(26, x + 120, localY, linkedItem.display.durabilityShow); scrollWindow.addButton(durabilityButton); + localY += 30; + + // Animation controls + scrollWindow.addLabel(new GuiNpcLabel(80, "gui.animated", x, localY + 5, 0xFFFFFF)); + boolean isAnim = linkedItem.display.animated != null && linkedItem.display.animated; + GuiNpcButtonYesNo animBtn = new GuiNpcButtonYesNo(29, x + 120, localY, isAnim); + animBtn.setHoverText("gui.animated.hover"); + scrollWindow.addButton(animBtn); + localY += 25; + + if (isAnim) { + scrollWindow.addLabel(new GuiNpcLabel(81, "gui.frameCount", x, localY + 5, 0xFFFFFF)); + GuiNpcTextField fcField = new GuiNpcTextField(71, this, x + 120, localY, 60, 20, + "" + (linkedItem.display.frameCount != null ? linkedItem.display.frameCount : 1)); + fcField.setIntegersOnly().setMinMaxDefault(1, 256, 1); + scrollWindow.addTextField(fcField); + localY += 25; + + scrollWindow.addLabel(new GuiNpcLabel(82, "gui.frameTime", x, localY + 5, 0xFFFFFF)); + GuiNpcTextField ftField = new GuiNpcTextField(72, this, x + 120, localY, 60, 20, + "" + (linkedItem.display.frametime != null ? linkedItem.display.frametime : 2)); + ftField.setIntegersOnly().setMinMaxDefault(1, 100, 2); + scrollWindow.addTextField(ftField); + localY += 25; + } scrollWindow.scrollY = 0; scrollWindow.maxScrollY = Math.max(localY - scrollWindow.clipHeight, 0); @@ -381,6 +407,9 @@ public class SubGuiLinkedItem extends SubGuiInterface implements ITextfieldListe linkedItem.isNormalItem = ((GuiNpcButtonYesNo) guibutton).getBoolean(); } else if (id == 26 && guibutton instanceof GuiNpcButtonYesNo) { linkedItem.display.durabilityShow = ((GuiNpcButtonYesNo) guibutton).getBoolean(); + } else if (id == 29 && guibutton instanceof GuiNpcButtonYesNo) { + linkedItem.display.animated = ((GuiNpcButtonYesNo) guibutton).getBoolean(); + initGui(); } } @@ -429,6 +458,10 @@ public class SubGuiLinkedItem extends SubGuiInterface implements ITextfieldListe linkedItem.display.translateY = textField.getFloat(); } else if (id == 63) { linkedItem.display.translateZ = textField.getFloat(); + } else if (id == 71) { + linkedItem.display.frameCount = Math.max(1, textField.getInteger()); + } else if (id == 72) { + linkedItem.display.frametime = Math.max(1, textField.getInteger()); } } @@ -472,7 +505,20 @@ public class SubGuiLinkedItem extends SubGuiInterface implements ITextfieldListe int iconWidth = data.getTotalWidth(); int iconHeight = data.getTotalWidth(); int width = data.getTotalWidth(); - int height = data.getTotalWidth(); + int height = data.getTotalHeight(); + + // Animation V offset for preview + if (data.isAnimated()) { + iconY += (int) (data.getCurrentFrameVOffset() * height); + iconHeight = data.getFrameHeight(); + } else if (linkedItem.display.animated != null && linkedItem.display.animated + && linkedItem.display.frameCount != null && linkedItem.display.frameCount > 1) { + iconY += (int) (AnimationHelper.getFrameVOffset(height, linkedItem.display.frameCount, linkedItem.display.frametime) * height); + iconHeight = height / linkedItem.display.frameCount; + } else { + iconHeight = data.getTotalWidth(); + height = data.getTotalWidth(); + } float[] colors = ColorUtil.hexToRGB(linkedItem.display.itemColor); GL11.glColor3f(colors[0], colors[1], colors[2]); diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiEffectBar.java b/src/main/java/noppes/npcs/client/gui/util/GuiEffectBar.java index 29cf26134..b27f36519 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiEffectBar.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiEffectBar.java @@ -6,6 +6,7 @@ import net.minecraft.client.gui.GuiScreen; import net.minecraft.client.gui.ScaledResolution; import net.minecraft.client.renderer.texture.TextureManager; import noppes.npcs.client.ClientCacheHandler; +import noppes.npcs.client.renderer.AnimationHelper; import noppes.npcs.client.renderer.ImageData; import noppes.npcs.controllers.data.CustomEffect; import noppes.npcs.controllers.data.PlayerEffect; @@ -82,6 +83,13 @@ public class GuiEffectBar extends GuiScreen { int texWidth = imageData.getTotalWidth(); int texHeight = imageData.getTotalHeight(); + // Animation V offset + if (imageData.isAnimated()) { + iconV += (int) (imageData.getCurrentFrameVOffset() * texHeight); + } else if (entry.effect.animated && entry.effect.frameCount > 1) { + iconV += (int) (AnimationHelper.getFrameVOffset(texHeight, entry.effect.frameCount, entry.effect.frametime) * texHeight); + } + // Use iconYOffset to vertically center the icon func_152125_a(x + 2, drawY + iconYOffset, iconU, iconV, iconWidth, iconHeight, iconRenderSize, iconRenderSize, texWidth, texHeight); @@ -191,7 +199,14 @@ public class GuiEffectBar extends GuiScreen { if (imageData != null && imageData.imageLoaded()) { GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); imageData.bindTexture(); - func_152125_a(tooltipX + (maxWidth - iconSize) / 2, textY + 4, hoveredEffect.iconX, hoveredEffect.iconY, hoveredEffect.getWidth(), hoveredEffect.getHeight(), iconSize, iconSize, imageData.getTotalWidth(), imageData.getTotalHeight()); + int tooltipIconV = hoveredEffect.iconY; + int tooltipTexH = imageData.getTotalHeight(); + if (imageData.isAnimated()) { + tooltipIconV += (int) (imageData.getCurrentFrameVOffset() * tooltipTexH); + } else if (hoveredEffect.animated && hoveredEffect.frameCount > 1) { + tooltipIconV += (int) (AnimationHelper.getFrameVOffset(tooltipTexH, hoveredEffect.frameCount, hoveredEffect.frametime) * tooltipTexH); + } + func_152125_a(tooltipX + (maxWidth - iconSize) / 2, textY + 4, hoveredEffect.iconX, tooltipIconV, hoveredEffect.getWidth(), hoveredEffect.getHeight(), iconSize, iconSize, imageData.getTotalWidth(), tooltipTexH); } textureManager.bindTexture(specialIcons); diff --git a/src/main/java/noppes/npcs/client/renderer/AnimationHelper.java b/src/main/java/noppes/npcs/client/renderer/AnimationHelper.java new file mode 100644 index 000000000..c847d6e14 --- /dev/null +++ b/src/main/java/noppes/npcs/client/renderer/AnimationHelper.java @@ -0,0 +1,49 @@ +package noppes.npcs.client.renderer; + +import net.minecraft.client.Minecraft; + +/** + * Static utility for computing animated texture UV offsets. + * Used by renderers when animation parameters come from data class fields + * rather than .mcmeta auto-detection on ImageData. + */ +public class AnimationHelper { + + /** + * Returns the V-coordinate offset (0.0-1.0) for the current animation frame. + * Based on system time for consistent sync across all animated textures. + * + * @param totalHeight Total pixel height of the sprite strip + * @param frameCount Number of frames in the strip + * @param frametime Ticks per frame (1 tick = 50ms) + * @return V offset in 0.0-1.0 range, or 0.0 if not animated + */ + public static float getFrameVOffset(int totalHeight, int frameCount, int frametime) { + if (frameCount <= 1 || totalHeight <= 0) { + return 0f; + } + + frametime = Math.max(1, frametime); + long millis = Minecraft.getSystemTime(); + int tick = (int) (millis / 50L); + int frameIdx = (tick / frametime) % frameCount; + int frameHeight = totalHeight / frameCount; + + return (float) (frameIdx * frameHeight) / (float) totalHeight; + } + + /** + * Returns the V-coordinate size of a single frame in 0.0-1.0 range. + * + * @param totalHeight Total pixel height of the sprite strip + * @param frameCount Number of frames in the strip + * @return V size of one frame, or 1.0 if not animated + */ + public static float getFrameVSize(int totalHeight, int frameCount) { + if (frameCount <= 1 || totalHeight <= 0) { + return 1f; + } + int frameHeight = totalHeight / frameCount; + return (float) frameHeight / (float) totalHeight; + } +} diff --git a/src/main/java/noppes/npcs/client/renderer/ImageData.java b/src/main/java/noppes/npcs/client/renderer/ImageData.java index 8e6f651c5..b85e51502 100644 --- a/src/main/java/noppes/npcs/client/renderer/ImageData.java +++ b/src/main/java/noppes/npcs/client/renderer/ImageData.java @@ -1,5 +1,8 @@ package noppes.npcs.client.renderer; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.texture.TextureManager; import net.minecraft.client.resources.IResource; @@ -11,6 +14,7 @@ import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; public class ImageData { private final ResourceLocation location; @@ -23,6 +27,14 @@ public class ImageData { private boolean gotWidthHeight; private boolean invalid; + // Animation fields + private boolean animated = false; + private int frameCount = 1; + private int frametime = 2; + private int[] frames = null; + private boolean interpolate = false; + private boolean mcmetaChecked = false; + public ImageData(String directory) { this.location = new ResourceLocation(directory); if (directory.startsWith("https://") || directory.startsWith("http://")) { @@ -141,6 +153,11 @@ public class ImageData { private void correctWidthHeight() { this.totalWidth = Math.max(this.totalWidth, 1); this.totalHeight = Math.max(this.totalHeight, 1); + + if (!this.isUrl && !this.mcmetaChecked) { + this.mcmetaChecked = true; + tryLoadMcmeta(); + } } public int getTotalWidth() { @@ -150,4 +167,112 @@ public class ImageData { public int getTotalHeight() { return this.gotWidthHeight ? this.totalHeight : -1; } + + // ═══════════════════════════════════════════════════════════════════ + // ANIMATION + // ═══════════════════════════════════════════════════════════════════ + + private void tryLoadMcmeta() { + try { + ResourceLocation mcmetaLoc = new ResourceLocation( + location.getResourceDomain(), + location.getResourcePath() + ".mcmeta" + ); + IResource resource = Minecraft.getMinecraft().getResourceManager().getResource(mcmetaLoc); + InputStream stream = resource.getInputStream(); + try { + InputStreamReader reader = new InputStreamReader(stream); + JsonObject root = new JsonParser().parse(reader).getAsJsonObject(); + if (root.has("animation")) { + JsonObject anim = root.getAsJsonObject("animation"); + this.animated = true; + + if (anim.has("frametime")) { + this.frametime = Math.max(1, anim.get("frametime").getAsInt()); + } + if (anim.has("interpolate")) { + this.interpolate = anim.get("interpolate").getAsBoolean(); + } + if (anim.has("frames")) { + JsonArray framesArray = anim.getAsJsonArray("frames"); + this.frames = new int[framesArray.size()]; + int maxFrame = 0; + for (int i = 0; i < framesArray.size(); i++) { + this.frames[i] = framesArray.get(i).getAsInt(); + if (this.frames[i] > maxFrame) { + maxFrame = this.frames[i]; + } + } + this.frameCount = maxFrame + 1; + } else if (this.totalWidth > 0) { + this.frameCount = Math.max(1, this.totalHeight / this.totalWidth); + } + } + } finally { + stream.close(); + } + } catch (Exception ignored) { + // No .mcmeta found or parse error — not animated + } + } + + /** + * Programmatically enable animation on this ImageData. + * Used when animation settings come from data class fields rather than .mcmeta files. + */ + public void setAnimation(int frameCount, int frametime) { + if (frameCount > 1) { + this.animated = true; + this.frameCount = Math.max(1, frameCount); + this.frametime = Math.max(1, frametime); + } + } + + public boolean isAnimated() { + return this.animated && this.frameCount > 1; + } + + public int getFrameCount() { + return this.frameCount; + } + + public int getFrameTime() { + return this.frametime; + } + + /** + * Returns the pixel height of a single animation frame. + * For non-animated textures, returns the total height. + */ + public int getFrameHeight() { + if (this.animated && this.frameCount > 1) { + return Math.max(1, this.totalHeight / this.frameCount); + } + return this.gotWidthHeight ? this.totalHeight : -1; + } + + /** + * Returns the V-coordinate offset (0.0-1.0) for the current animation frame. + * Based on system time so all instances of the same texture animate in sync. + * Returns 0.0 for non-animated textures. + */ + public float getCurrentFrameVOffset() { + if (!this.animated || this.frameCount <= 1 || this.totalHeight <= 0) { + return 0f; + } + + long millis = Minecraft.getSystemTime(); + int tick = (int) (millis / 50L); + int frameIdx; + + if (this.frames != null && this.frames.length > 0) { + frameIdx = (tick / this.frametime) % this.frames.length; + frameIdx = this.frames[frameIdx]; + } else { + frameIdx = (tick / this.frametime) % this.frameCount; + } + + int frameHeight = this.totalHeight / this.frameCount; + return (float) (frameIdx * frameHeight) / (float) this.totalHeight; + } } diff --git a/src/main/java/noppes/npcs/client/renderer/items/ItemCustomRenderer.java b/src/main/java/noppes/npcs/client/renderer/items/ItemCustomRenderer.java index 8b8c1f452..83b83b9b6 100644 --- a/src/main/java/noppes/npcs/client/renderer/items/ItemCustomRenderer.java +++ b/src/main/java/noppes/npcs/client/renderer/items/ItemCustomRenderer.java @@ -17,6 +17,7 @@ import net.minecraftforge.client.IItemRenderer; import noppes.npcs.api.item.IItemCustomizable; import noppes.npcs.api.item.IItemStack; import noppes.npcs.client.ClientCacheHandler; +import noppes.npcs.client.renderer.AnimationHelper; import noppes.npcs.client.renderer.ImageData; import noppes.npcs.items.ItemCustomizable; import noppes.npcs.scripted.NpcAPI; @@ -222,7 +223,8 @@ public class ItemCustomRenderer implements IItemRenderer { int pass = 0; GL11.glPushMatrix(); - ClientCacheHandler.getImageData(scriptCustomItem.getTexture()).bindTexture(); + ImageData entityImageData = ClientCacheHandler.getImageData(scriptCustomItem.getTexture()); + entityImageData.bindTexture(); if (renderInFrame) { GL11.glTranslatef(0.0F, -0.05F, 0.0F); @@ -233,10 +235,22 @@ public class ItemCustomRenderer implements IItemRenderer { Tessellator tessellator = Tessellator.instance; + // Animation V offset for entity rendering + float entityVOff = 0f; + float entityVEnd = 1f; + if (entityImageData.isAnimated()) { + entityVOff = entityImageData.getCurrentFrameVOffset(); + entityVEnd = entityVOff + (float) entityImageData.getFrameHeight() / entityImageData.getTotalHeight(); + } else if (scriptCustomItem.isTextureAnimated() != null && scriptCustomItem.isTextureAnimated() + && scriptCustomItem.getFrameCount() != null && scriptCustomItem.getFrameCount() > 1) { + entityVOff = AnimationHelper.getFrameVOffset(entityImageData.getTotalHeight(), scriptCustomItem.getFrameCount(), scriptCustomItem.getFrameTime()); + entityVEnd = entityVOff + AnimationHelper.getFrameVSize(entityImageData.getTotalHeight(), scriptCustomItem.getFrameCount()); + } + float f14 = 0.0F; float f15 = 1.0F; - float f4 = 0.0F; - float f5 = 1.0F; + float f4 = entityVOff; + float f5 = entityVEnd; float f6 = 1.0F; float f7 = 0.5F; float f8 = 0.25F; @@ -272,8 +286,11 @@ public class ItemCustomRenderer implements IItemRenderer { GL11.glRotatef(-90.0F, 0.0F, 0.0F, 1.0F); }*/ - ImageData imageData = ClientCacheHandler.getImageData(scriptCustomItem.getTexture()); - ItemRenderer.renderItemIn2D(tessellator, f15, f4, f14, f5, imageData.getTotalWidth(), imageData.getTotalHeight(), f9); + ImageData loopImageData = ClientCacheHandler.getImageData(scriptCustomItem.getTexture()); + int entityFrameH = loopImageData.isAnimated() ? loopImageData.getFrameHeight() + : (scriptCustomItem.isTextureAnimated() != null && scriptCustomItem.isTextureAnimated() && scriptCustomItem.getFrameCount() != null && scriptCustomItem.getFrameCount() > 1 + ? loopImageData.getTotalHeight() / scriptCustomItem.getFrameCount() : loopImageData.getTotalHeight()); + ItemRenderer.renderItemIn2D(tessellator, f15, f4, f14, f5, loopImageData.getTotalWidth(), entityFrameH, f9); if (itemStack.hasEffect(pass)) { GL11.glDepthFunc(GL11.GL_EQUAL); @@ -359,8 +376,20 @@ public class ItemCustomRenderer implements IItemRenderer { GL11.glDisable(GL11.GL_LIGHTING); //Forge: Make sure that render states are reset, a renderEffect can derp them up. GL11.glEnable(GL11.GL_ALPHA_TEST); - ClientCacheHandler.getImageData(scriptCustomItem.getTexture()).bindTexture(); - renderCustomItemSlot(0, 0, 16, 16, itemRed, itemGreen, itemBlue); + ImageData imageData = ClientCacheHandler.getImageData(scriptCustomItem.getTexture()); + imageData.bindTexture(); + + float vOff = 0f; + float vEnd = 1f; + if (imageData.isAnimated()) { + vOff = imageData.getCurrentFrameVOffset(); + vEnd = vOff + (float) imageData.getFrameHeight() / imageData.getTotalHeight(); + } else if (scriptCustomItem.isTextureAnimated() != null && scriptCustomItem.isTextureAnimated() + && scriptCustomItem.getFrameCount() != null && scriptCustomItem.getFrameCount() > 1) { + vOff = AnimationHelper.getFrameVOffset(imageData.getTotalHeight(), scriptCustomItem.getFrameCount(), scriptCustomItem.getFrameTime()); + vEnd = vOff + AnimationHelper.getFrameVSize(imageData.getTotalHeight(), scriptCustomItem.getFrameCount()); + } + renderCustomItemSlot(0, 0, 16, 16, itemRed, itemGreen, itemBlue, vOff, vEnd); GL11.glEnable(GL11.GL_LIGHTING); GL11.glDisable(GL11.GL_ALPHA_TEST); @@ -412,13 +441,17 @@ public class ItemCustomRenderer implements IItemRenderer { } public void renderCustomItemSlot(int posX, int posY, int imageWidth, int imageHeight, float itemRed, float itemGreen, float itemBlue) { + renderCustomItemSlot(posX, posY, imageWidth, imageHeight, itemRed, itemGreen, itemBlue, 0f, 1f); + } + + public void renderCustomItemSlot(int posX, int posY, int imageWidth, int imageHeight, float itemRed, float itemGreen, float itemBlue, float vOff, float vEnd) { Tessellator tessellator = Tessellator.instance; tessellator.startDrawingQuads(); tessellator.setColorRGBA_F(itemRed, itemGreen, itemBlue, 1.0F); - tessellator.addVertexWithUV((double) (posX), (double) (posY + imageHeight), 0, 0, 1); - tessellator.addVertexWithUV((double) (posX + imageWidth), (double) (posY + imageHeight), 0, 1, 1); - tessellator.addVertexWithUV((double) (posX + imageWidth), (double) (posY), 0, 1, 0); - tessellator.addVertexWithUV((double) (posX), (double) (posY), 0, 0, 0); + tessellator.addVertexWithUV((double) (posX), (double) (posY + imageHeight), 0, 0, vEnd); + tessellator.addVertexWithUV((double) (posX + imageWidth), (double) (posY + imageHeight), 0, 1, vEnd); + tessellator.addVertexWithUV((double) (posX + imageWidth), (double) (posY), 0, 1, vOff); + tessellator.addVertexWithUV((double) (posX), (double) (posY), 0, 0, vOff); tessellator.draw(); } @@ -429,7 +462,8 @@ public class ItemCustomRenderer implements IItemRenderer { TextureManager texturemanager = mc.getTextureManager(); int par3 = 0; - ClientCacheHandler.getImageData(scriptCustomItem.getTexture()).bindTexture(); + ImageData item3dImageData = ClientCacheHandler.getImageData(scriptCustomItem.getTexture()); + item3dImageData.bindTexture(); Tessellator tessellator = Tessellator.instance; IIcon icon = entityLivingBase.getItemIcon(itemStack, par3); @@ -447,7 +481,19 @@ public class ItemCustomRenderer implements IItemRenderer { GL11.glRotatef(335.0F, 0.0F, 0.0F, 1.0F); GL11.glTranslatef(-0.9375F, -0.0625F, 0.0F); - renderCustomItemIn2D(scriptCustomItem, tessellator, 1.0F, 0.0F, 0.0F, 1.0F, 0.0625F); + // Animation V offset for 3D handheld rendering + float item3dVOff = 0f; + float item3dVEnd = 1f; + if (item3dImageData.isAnimated()) { + item3dVOff = item3dImageData.getCurrentFrameVOffset(); + item3dVEnd = item3dVOff + (float) item3dImageData.getFrameHeight() / item3dImageData.getTotalHeight(); + } else if (scriptCustomItem.isTextureAnimated() != null && scriptCustomItem.isTextureAnimated() + && scriptCustomItem.getFrameCount() != null && scriptCustomItem.getFrameCount() > 1) { + item3dVOff = AnimationHelper.getFrameVOffset(item3dImageData.getTotalHeight(), scriptCustomItem.getFrameCount(), scriptCustomItem.getFrameTime()); + item3dVEnd = item3dVOff + AnimationHelper.getFrameVSize(item3dImageData.getTotalHeight(), scriptCustomItem.getFrameCount()); + } + + renderCustomItemIn2D(scriptCustomItem, tessellator, 1.0F, item3dVOff, 0.0F, item3dVEnd, 0.0625F); if (itemStack.hasEffect(par3)) { GL11.glDepthFunc(GL11.GL_EQUAL); @@ -485,7 +531,10 @@ public class ItemCustomRenderer implements IItemRenderer { public static void renderCustomItemIn2D(IItemCustomizable wrapper, Tessellator p_78439_0_, float p_78439_1_, float p_78439_2_, float p_78439_3_, float p_78439_4_, float p_78439_7_) { ImageData imageData = ClientCacheHandler.getImageData(wrapper.getTexture()); int width = imageData.getTotalWidth(); - int height = imageData.getTotalHeight(); + // Use frame height for edge pixel calculations so animated strips render correctly + int height = imageData.isAnimated() ? imageData.getFrameHeight() + : (wrapper.isTextureAnimated() != null && wrapper.isTextureAnimated() && wrapper.getFrameCount() != null && wrapper.getFrameCount() > 1 + ? imageData.getTotalHeight() / wrapper.getFrameCount() : imageData.getTotalHeight()); p_78439_0_.startDrawingQuads(); p_78439_0_.setNormal(0.0F, 0.0F, 1.0F); diff --git a/src/main/java/noppes/npcs/controllers/data/CustomEffect.java b/src/main/java/noppes/npcs/controllers/data/CustomEffect.java index c088204e5..aec4eaf6e 100644 --- a/src/main/java/noppes/npcs/controllers/data/CustomEffect.java +++ b/src/main/java/noppes/npcs/controllers/data/CustomEffect.java @@ -29,6 +29,11 @@ public class CustomEffect implements ICustomEffect { public String menuName = "§aNEW EFFECT"; public int width = 16, height = 16; + // Animation + public boolean animated = false; + public int frameCount = 1; + public int frametime = 2; + public int index = 0; public CustomEffect() { @@ -137,6 +142,36 @@ public class CustomEffect implements ICustomEffect { this.height = height; } + @Override + public boolean isAnimated() { + return animated; + } + + @Override + public void setAnimated(boolean animated) { + this.animated = animated; + } + + @Override + public int getFrameCount() { + return frameCount; + } + + @Override + public void setFrameCount(int frameCount) { + this.frameCount = Math.max(1, frameCount); + } + + @Override + public int getFrameTime() { + return frametime; + } + + @Override + public void setFrameTime(int frametime) { + this.frametime = Math.max(1, frametime); + } + @Override public boolean isLossOnDeath() { return lossOnDeath; @@ -222,6 +257,9 @@ public class CustomEffect implements ICustomEffect { compound.setInteger("iconHeight", height); compound.setString("icon", icon); compound.setBoolean("lossOnDeath", lossOnDeath); + compound.setBoolean("iconAnimated", animated); + compound.setInteger("iconFrameCount", frameCount); + compound.setInteger("iconFrameTime", frametime); if (saveScripts) { NBTTagCompound scriptData = new NBTTagCompound(); @@ -264,6 +302,13 @@ public class CustomEffect implements ICustomEffect { icon = compound.getString("icon"); lossOnDeath = compound.getBoolean("lossOnDeath"); + if (compound.hasKey("iconAnimated")) + animated = compound.getBoolean("iconAnimated"); + if (compound.hasKey("iconFrameCount")) + frameCount = Math.max(1, compound.getInteger("iconFrameCount")); + if (compound.hasKey("iconFrameTime")) + frametime = Math.max(1, compound.getInteger("iconFrameTime")); + if (compound.hasKey("ScriptData", Constants.NBT.TAG_COMPOUND)) { EffectScript handler = new EffectScript(); handler.readFromNBT(compound.getCompoundTag("ScriptData")); diff --git a/src/main/java/noppes/npcs/controllers/data/ItemDisplayData.java b/src/main/java/noppes/npcs/controllers/data/ItemDisplayData.java index e40452bfc..c26edbf0c 100644 --- a/src/main/java/noppes/npcs/controllers/data/ItemDisplayData.java +++ b/src/main/java/noppes/npcs/controllers/data/ItemDisplayData.java @@ -12,6 +12,11 @@ public class ItemDisplayData { public Boolean durabilityShow = false; public Integer durabilityColor = -1; + // Animation + public Boolean animated = false; + public Integer frameCount = 1; + public Integer frametime = 2; + public NBTTagCompound writeToNBT() { NBTTagCompound compound = new NBTTagCompound(); this.writeToNBT(compound); @@ -42,6 +47,10 @@ public class ItemDisplayData { this.setFloat(compound, "TranslateY", this.translateY); this.setFloat(compound, "TranslateZ", this.translateZ); + this.setBoolean(compound, "Animated", this.animated); + this.setInteger(compound, "FrameCount", this.frameCount); + this.setInteger(compound, "FrameTime", this.frametime); + return compound; } @@ -97,6 +106,16 @@ public class ItemDisplayData { if (compound.hasKey("TranslateZ")) { this.translateZ = compound.getFloat("TranslateZ"); } + + if (compound.hasKey("Animated")) { + this.animated = compound.getBoolean("Animated"); + } + if (compound.hasKey("FrameCount")) { + this.frameCount = Math.max(1, compound.getInteger("FrameCount")); + } + if (compound.hasKey("FrameTime")) { + this.frametime = Math.max(1, compound.getInteger("FrameTime")); + } } private void setBoolean(NBTTagCompound compound, String key, Boolean value) { diff --git a/src/main/java/noppes/npcs/scripted/gui/ScriptGuiTexturedRect.java b/src/main/java/noppes/npcs/scripted/gui/ScriptGuiTexturedRect.java index 4b9ead83e..05da1d779 100644 --- a/src/main/java/noppes/npcs/scripted/gui/ScriptGuiTexturedRect.java +++ b/src/main/java/noppes/npcs/scripted/gui/ScriptGuiTexturedRect.java @@ -11,6 +11,11 @@ public class ScriptGuiTexturedRect extends ScriptGuiComponent implements ITextur float scale; String texture; + // Animation + boolean animated = false; + int frameCount = 1; + int frametime = 2; + public ScriptGuiTexturedRect() { this.textureY = -1; this.scale = 1.0F; @@ -76,6 +81,25 @@ public class ScriptGuiTexturedRect extends ScriptGuiComponent implements ITextur return this; } + public boolean isAnimated() { + return this.animated && this.frameCount > 1; + } + + public int getFrameCount() { + return this.frameCount; + } + + public int getFrameTime() { + return this.frametime; + } + + public ITexturedRect setAnimation(int frameCount, int frametime) { + this.animated = frameCount > 1; + this.frameCount = Math.max(1, frameCount); + this.frametime = Math.max(1, frametime); + return this; + } + public int getType() { return 2; } @@ -89,6 +113,12 @@ public class ScriptGuiTexturedRect extends ScriptGuiComponent implements ITextur nbt.setIntArray("texPos", new int[]{this.textureX, this.textureY}); } + if (this.animated) { + nbt.setBoolean("animated", true); + nbt.setInteger("frameCount", this.frameCount); + nbt.setInteger("frameTime", this.frametime); + } + return nbt; } @@ -101,6 +131,13 @@ public class ScriptGuiTexturedRect extends ScriptGuiComponent implements ITextur this.setTextureOffset(nbt.getIntArray("texPos")[0], nbt.getIntArray("texPos")[1]); } + if (nbt.hasKey("animated") && nbt.getBoolean("animated")) { + this.setAnimation( + nbt.hasKey("frameCount") ? Math.max(1, nbt.getInteger("frameCount")) : 1, + nbt.hasKey("frameTime") ? Math.max(1, nbt.getInteger("frameTime")) : 2 + ); + } + return this; } } diff --git a/src/main/java/noppes/npcs/scripted/item/ScriptCustomizableItem.java b/src/main/java/noppes/npcs/scripted/item/ScriptCustomizableItem.java index 77e51df2c..a029b4ba4 100644 --- a/src/main/java/noppes/npcs/scripted/item/ScriptCustomizableItem.java +++ b/src/main/java/noppes/npcs/scripted/item/ScriptCustomizableItem.java @@ -147,6 +147,33 @@ public abstract class ScriptCustomizableItem extends ScriptItemStack implements return this.itemDisplay.translateZ != null ? this.itemDisplay.translateZ : 0; } + public Boolean isTextureAnimated() { + return this.itemDisplay.animated != null ? this.itemDisplay.animated : false; + } + + public void setTextureAnimated(Boolean animated) { + this.itemDisplay.animated = animated; + saveItemData(); + } + + public Integer getFrameCount() { + return this.itemDisplay.frameCount != null ? this.itemDisplay.frameCount : 1; + } + + public void setFrameCount(Integer frameCount) { + this.itemDisplay.frameCount = frameCount != null ? Math.max(1, frameCount) : 1; + saveItemData(); + } + + public Integer getFrameTime() { + return this.itemDisplay.frametime != null ? this.itemDisplay.frametime : 2; + } + + public void setFrameTime(Integer frametime) { + this.itemDisplay.frametime = frametime != null ? Math.max(1, frametime) : 2; + saveItemData(); + } + public NBTTagCompound getMCNbt() { NBTTagCompound compound = super.getMCNbt(); compound.setTag("ItemData", this.getItemNBT(new NBTTagCompound())); diff --git a/src/main/resources/assets/customnpcs/lang/en_US.lang b/src/main/resources/assets/customnpcs/lang/en_US.lang index 41126b303..86c7db40a 100644 --- a/src/main/resources/assets/customnpcs/lang/en_US.lang +++ b/src/main/resources/assets/customnpcs/lang/en_US.lang @@ -182,6 +182,10 @@ gui.lineColor3=Lower line color gui.slotColour=Slots color gui.buttonAcceptColour=Accept button color gui.buttonRejectColour=Reject button color +gui.animated=Animated +gui.animated.hover=Textures with a .mcmeta file animate automatically.\nEnable this for manual animation of sprite sheet textures. +gui.frameCount=Frame Count +gui.frameTime=Frame Time questgui.doyouaccept=Do you accept the quest? questgui.bringitems=Bring items questgui.readdialog=Read dialog