diff --git a/build.gradle.kts b/build.gradle.kts index 944a269..406e913 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,3 +2,19 @@ plugins { id("com.gtnewhorizons.gtnhconvention") } + +tasks.named("test") { + useJUnitPlatform() +} + +tasks.named("test") { + useJUnitPlatform() +} + +tasks.named("test") { + useJUnitPlatform() +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/dependencies.gradle b/dependencies.gradle index 72708f4..920a171 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -30,4 +30,6 @@ dependencies { runtimeOnlyNonPublishable deobfCurse('skinport-234948:3212017') runtimeOnlyNonPublishable deobfCurse("angelica-988067:8419617") + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' } diff --git a/gradle.properties b/gradle.properties index 94fc1b7..f246ec4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,8 @@ +modName = NecroTempus # ExampleMod tag to use as Blowdryer (Spotless, etc.) settings version, leave empty to disable. # LOCAL to test local config updates. gtnh.settings.blowdryerTag = 0.2.2 -modName = NecroTempus # This is a case-sensitive string to identify your mod. Convention is to use lower case. modId = necrotempus diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..b30b550 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/df211d3c3eefdc408b462041881bc575/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/b41931cf1e70bc8e08d7dd19c343ef00/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/df211d3c3eefdc408b462041881bc575/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/b41931cf1e70bc8e08d7dd19c343ef00/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/46949723aaa20c7b64d7ecfed7207034/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d6690dfd71c4c91e08577437b5b2beb0/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/df211d3c3eefdc408b462041881bc575/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/b41931cf1e70bc8e08d7dd19c343ef00/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3cd7045fca9a72cd9bc7d14a385e594c/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/552c7bffe0370c66410a51c55985b511/redirect +toolchainVersion=25 diff --git a/src/main/java/io/github/cruciblemc/necrotempus/NecroTempus.java b/src/main/java/io/github/cruciblemc/necrotempus/NecroTempus.java index 3e4a9b8..29c10b9 100644 --- a/src/main/java/io/github/cruciblemc/necrotempus/NecroTempus.java +++ b/src/main/java/io/github/cruciblemc/necrotempus/NecroTempus.java @@ -21,6 +21,8 @@ import io.github.cruciblemc.necrotempus.modules.features.bossbar.network.BossBarPacket; import io.github.cruciblemc.necrotempus.modules.features.bossbar.network.BossBarPacketHandler; import io.github.cruciblemc.necrotempus.modules.features.core.ClientResetState; +import io.github.cruciblemc.necrotempus.modules.features.glow.network.GlowPacket; +import io.github.cruciblemc.necrotempus.modules.features.glow.network.GlowPacketHandler; import io.github.cruciblemc.necrotempus.modules.features.packet.NTClientPacket; import io.github.cruciblemc.necrotempus.modules.features.packet.NTClientPacketHandler; import io.github.cruciblemc.necrotempus.modules.features.playertab.network.PlayerTabPacket; @@ -61,6 +63,7 @@ public class NecroTempus { DISPATCHER.registerMessage(PlayerTabPacketHandler.class, PlayerTabPacket.class, 2, Side.CLIENT); DISPATCHER.registerMessage(TitlePacketHandler.class, TitlePacket.class, 3, Side.CLIENT); DISPATCHER.registerMessage(ActionBarPacketHandler.class, ActionBarPacket.class, 4, Side.CLIENT); + DISPATCHER.registerMessage(GlowPacketHandler.class, GlowPacket.class, 5, Side.CLIENT); } @Mod.EventHandler diff --git a/src/main/java/io/github/cruciblemc/necrotempus/NecroTempusConfig.java b/src/main/java/io/github/cruciblemc/necrotempus/NecroTempusConfig.java index 0c207b5..8bfe34c 100644 --- a/src/main/java/io/github/cruciblemc/necrotempus/NecroTempusConfig.java +++ b/src/main/java/io/github/cruciblemc/necrotempus/NecroTempusConfig.java @@ -2,6 +2,7 @@ import io.github.cruciblemc.omniconfig.api.annotation.AnnotationConfig; import io.github.cruciblemc.omniconfig.api.annotation.properties.ConfigBoolean; +import io.github.cruciblemc.omniconfig.api.annotation.properties.ConfigInt; import io.github.cruciblemc.omniconfig.api.annotation.properties.ConfigString; import io.github.cruciblemc.omniconfig.api.core.VersioningPolicy; @@ -74,4 +75,21 @@ public class NecroTempusConfig { category = "Angelica") public static boolean angelicaModernFontsIntegration = true; + @ConfigBoolean(name = "enableEntityGlow", comment = "Enable the 1.9 entity glow outline.", category = "EntityGlow") + public static boolean enableEntityGlow = true; + + @ConfigInt( + name = "glowOutlineWidth", + comment = "Outline thickness in pixels (1-4).", + category = "EntityGlow", + min = 1, + max = 4) + public static int glowOutlineWidth = 4; + + @ConfigBoolean( + name = "glowDebugKey", + comment = "Dev only: bind a key to glow the entity you look at.", + category = "EntityGlow") + public static boolean glowDebugKey = false; + } diff --git a/src/main/java/io/github/cruciblemc/necrotempus/api/glow/GlowManager.java b/src/main/java/io/github/cruciblemc/necrotempus/api/glow/GlowManager.java new file mode 100644 index 0000000..0f4c1d1 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/api/glow/GlowManager.java @@ -0,0 +1,28 @@ +package io.github.cruciblemc.necrotempus.api.glow; + +import net.minecraft.entity.player.EntityPlayerMP; + +import io.github.cruciblemc.necrotempus.NecroTempus; +import io.github.cruciblemc.necrotempus.modules.features.glow.network.GlowPacket; + +/** Server-side entry point: tell a viewer that an entity glows. Mirrors TitleManager/ActionBarManager. */ +public class GlowManager { + + public static void glow(EntityPlayerMP viewer, int entityId, int rgb, int durationTicks) { + if (viewer != null) { + NecroTempus.DISPATCHER.sendTo(new GlowPacket(GlowPacket.Op.SET, entityId, rgb, durationTicks), viewer); + } + } + + public static void unglow(EntityPlayerMP viewer, int entityId) { + if (viewer != null) { + NecroTempus.DISPATCHER.sendTo(new GlowPacket(GlowPacket.Op.REMOVE, entityId, -1, 0), viewer); + } + } + + public static void clear(EntityPlayerMP viewer) { + if (viewer != null) { + NecroTempus.DISPATCHER.sendTo(new GlowPacket(GlowPacket.Op.CLEAR, 0, -1, 0), viewer); + } + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/compat/AngelicaCompat.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/compat/AngelicaCompat.java new file mode 100644 index 0000000..16b7eb9 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/compat/AngelicaCompat.java @@ -0,0 +1,41 @@ +package io.github.cruciblemc.necrotempus.modules.features.compat; + +import java.lang.reflect.Field; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Detects Angelica (GTNH optimization mod) at runtime and forcefully disables + * its batched font renderer via reflection. This allows NecroTempus custom glyphs + * to render correctly, since the vanilla FontRenderer pipeline (renderStringAtPos + * -> renderCharAtPos) is used instead of Angelica's VBO-based batcher. + */ +public class AngelicaCompat { + + private static final Logger LOG = LogManager.getLogger("AngelicaCompat"); + private static boolean initialized = false; + + public static void disableBatchingIfPresent() { + if (initialized) return; + initialized = true; + + try { + final Class configClass = Class.forName("com.gtnewhorizons.angelica.config.AngelicaConfig"); + final Field field = configClass.getField("enableFontRenderer"); + + final boolean currentValue = field.getBoolean(null); + if (!currentValue) { + LOG.info("Angelica detected: batched font renderer is already disabled."); + return; + } + + field.setBoolean(null, false); + LOG.info("Angelica detected: forcefully disabled batched font renderer for glyph compatibility."); + } catch (ClassNotFoundException e) { + LOG.debug("Angelica not detected, skipping batched font renderer disable."); + } catch (Exception e) { + LOG.error("Failed to disable Angelica batched font renderer: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/GlowEntry.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/GlowEntry.java new file mode 100644 index 0000000..486ffca --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/GlowEntry.java @@ -0,0 +1,16 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow; + +/** Plain data: the colour and remaining lifetime of one glowing entity. */ +public class GlowEntry { + + /** 0xRRGGBB, or -1 for "no colour supplied". */ + public int rgb; + + /** Client ticks left before expiry; < 0 means infinite. */ + public int remainingTicks; + + public GlowEntry(int rgb, int remainingTicks) { + this.rgb = rgb; + this.remainingTicks = remainingTicks; + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/GlowingEntityRegistry.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/GlowingEntityRegistry.java new file mode 100644 index 0000000..6d9add6 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/GlowingEntityRegistry.java @@ -0,0 +1,60 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow; + +import java.util.HashMap; +import java.util.Map; + +/** + * Minecraft-free registry of glowing entities keyed by entity id. Pure logic so it + * is unit-testable. Entity resolution and colour fallback live in the render layer. + * All access happens on the client thread (mutations scheduled there by the packet + * handler; ticks and reads on the client/render thread). + */ +public class GlowingEntityRegistry { + + public static final int INFINITE = -1; + + private final Map entries = new HashMap<>(); + + public void set(int entityId, int rgb, int durationTicks) { + int remaining = durationTicks <= 0 ? INFINITE : durationTicks; + GlowEntry existing = entries.get(entityId); + if (existing != null) { + existing.rgb = rgb; + existing.remainingTicks = remaining; + } else { + entries.put(entityId, new GlowEntry(rgb, remaining)); + } + } + + public void remove(int entityId) { + entries.remove(entityId); + } + + public void clear() { + entries.clear(); + } + + public boolean isEmpty() { + return entries.isEmpty(); + } + + public Integer colorFor(int entityId) { + GlowEntry e = entries.get(entityId); + return e == null ? null : e.rgb; + } + + public int[] glowingIds() { + int[] ids = new int[entries.size()]; + int i = 0; + for (Integer id : entries.keySet()) ids[i++] = id; + return ids; + } + + public void tick() { + entries.values() + .removeIf(e -> { + if (e.remainingTicks < 0) return false; // infinite + return --e.remainingTicks <= 0; + }); + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/client/GlowClientManager.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/client/GlowClientManager.java new file mode 100644 index 0000000..974d729 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/client/GlowClientManager.java @@ -0,0 +1,73 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.client; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.settings.KeyBinding; +import net.minecraft.entity.Entity; +import net.minecraft.util.ChatComponentText; +import net.minecraft.util.MovingObjectPosition; + +import org.lwjgl.input.Keyboard; + +import cpw.mods.fml.client.registry.ClientRegistry; +import cpw.mods.fml.common.eventhandler.SubscribeEvent; +import cpw.mods.fml.common.gameevent.TickEvent; +import cpw.mods.fml.common.network.FMLNetworkEvent; +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import io.github.cruciblemc.necrotempus.NecroTempusConfig; +import io.github.cruciblemc.necrotempus.modules.features.glow.GlowingEntityRegistry; + +@SideOnly(Side.CLIENT) +public class GlowClientManager { + + private static final GlowClientManager INSTANCE = new GlowClientManager(); + + private final GlowingEntityRegistry registry = new GlowingEntityRegistry(); + private KeyBinding debugKey; + private boolean debugKeyWasDown = false; + + private GlowClientManager() {} + + public static GlowClientManager getInstance() { + return INSTANCE; + } + + public GlowingEntityRegistry registry() { + return registry; + } + + void registerDebugKey() { + if (NecroTempusConfig.glowDebugKey && debugKey == null) { + debugKey = new KeyBinding("Debug: Glow looked-at entity", Keyboard.KEY_G, "NecroTempus"); + ClientRegistry.registerKeyBinding(debugKey); + } + } + + @SubscribeEvent + public void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + registry.tick(); + handleDebugKey(); + } + + @SubscribeEvent + public void onDisconnect(FMLNetworkEvent.ClientDisconnectionFromServerEvent event) { + registry.clear(); + } + + private void handleDebugKey() { + if (debugKey == null) return; + Minecraft mc = Minecraft.getMinecraft(); + if (mc.thePlayer == null || mc.theWorld == null) return; + boolean down = debugKey.getIsKeyPressed(); + if (down && !debugKeyWasDown) { + MovingObjectPosition mop = mc.objectMouseOver; + if (mop != null && mop.entityHit != null) { + Entity e = mop.entityHit; + registry.set(e.getEntityId(), 0xFFFFFF, 200); + mc.thePlayer.addChatMessage(new ChatComponentText("Glowing entity " + e.getEntityId())); + } + } + debugKeyWasDown = down; + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/client/GlowRegistry.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/client/GlowRegistry.java new file mode 100644 index 0000000..fc09de0 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/client/GlowRegistry.java @@ -0,0 +1,14 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.client; + +import cpw.mods.fml.common.FMLCommonHandler; + +public interface GlowRegistry { + + static void init() { + GlowClientManager manager = GlowClientManager.getInstance(); + manager.registerDebugKey(); + FMLCommonHandler.instance() + .bus() + .register(manager); + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/network/GlowPacket.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/network/GlowPacket.java new file mode 100644 index 0000000..98e6d18 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/network/GlowPacket.java @@ -0,0 +1,60 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.network; + +import cpw.mods.fml.common.network.simpleimpl.IMessage; +import io.netty.buffer.ByteBuf; + +/** Server -> client: mark/unmark an entity as glowing. */ +public class GlowPacket implements IMessage { + + public enum Op { + SET, + REMOVE, + CLEAR + } + + private Op op = Op.CLEAR; + private int entityId; + private int rgb = -1; + private int durationTicks; + + public GlowPacket() {} + + public GlowPacket(Op op, int entityId, int rgb, int durationTicks) { + this.op = op; + this.entityId = entityId; + this.rgb = rgb; + this.durationTicks = durationTicks; + } + + @Override + public void fromBytes(ByteBuf buf) { + this.op = Op.values()[buf.readByte()]; + this.entityId = buf.readInt(); + this.rgb = buf.readInt(); + this.durationTicks = buf.readInt(); + } + + @Override + public void toBytes(ByteBuf buf) { + buf.writeByte(op.ordinal()); + buf.writeInt(entityId); + buf.writeInt(rgb); + buf.writeInt(durationTicks); + } + + public Op getOp() { + return op; + } + + public int getEntityId() { + return entityId; + } + + public int getRgb() { + return rgb; + } + + public int getDurationTicks() { + return durationTicks; + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/network/GlowPacketHandler.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/network/GlowPacketHandler.java new file mode 100644 index 0000000..be5bec5 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/network/GlowPacketHandler.java @@ -0,0 +1,43 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.network; + +import net.minecraft.client.Minecraft; + +import cpw.mods.fml.common.network.simpleimpl.IMessage; +import cpw.mods.fml.common.network.simpleimpl.IMessageHandler; +import cpw.mods.fml.common.network.simpleimpl.MessageContext; +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import io.github.cruciblemc.necrotempus.NecroTempusConfig; +import io.github.cruciblemc.necrotempus.modules.features.glow.GlowingEntityRegistry; +import io.github.cruciblemc.necrotempus.modules.features.glow.client.GlowClientManager; + +public class GlowPacketHandler implements IMessageHandler { + + @Override + public IMessage onMessage(GlowPacket message, MessageContext ctx) { + handle(message); + return null; + } + + @SideOnly(Side.CLIENT) + private static void handle(GlowPacket msg) { + if (!NecroTempusConfig.enableEntityGlow) return; + // onMessage runs on the netty thread; mutate registry on the client thread. + Minecraft.getMinecraft() + .func_152344_a(() -> { + GlowingEntityRegistry registry = GlowClientManager.getInstance() + .registry(); + switch (msg.getOp()) { + case SET: + registry.set(msg.getEntityId(), msg.getRgb(), msg.getDurationTicks()); + break; + case REMOVE: + registry.remove(msg.getEntityId()); + break; + case CLEAR: + registry.clear(); + break; + } + }); + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/AngelicaGlPlatform.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/AngelicaGlPlatform.java new file mode 100644 index 0000000..615b45a --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/AngelicaGlPlatform.java @@ -0,0 +1,53 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +import org.lwjgl.opengl.GL11; + +import com.gtnewhorizons.angelica.glsm.GLStateManager; + +/** + * Routes GL state through Angelica's GLStateManager. Loaded only after + * GlPlatformFactory confirms Angelica is present, so referencing GLStateManager + * here never triggers a NoClassDefFoundError when Angelica is absent. + */ +public class AngelicaGlPlatform implements GlPlatform { + + @Override + public void enable(int cap) { + GLStateManager.glEnable(cap); + } + + @Override + public void disable(int cap) { + GLStateManager.glDisable(cap); + } + + @Override + public boolean isEnabled(int cap) { + return GLStateManager.glIsEnabled(cap); + } + + @Override + public void blendFunc(int src, int dst) { + GLStateManager.glBlendFunc(src, dst); + } + + @Override + public void color4f(float r, float g, float b, float a) { + GLStateManager.glColor4f(r, g, b, a); + } + + @Override + public void bindTexture2D(int textureId) { + GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, textureId); + } + + @Override + public void useProgram(int program) { + GLStateManager.glUseProgram(program); + } + + @Override + public String name() { + return "Angelica"; + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlPlatform.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlPlatform.java new file mode 100644 index 0000000..fdfea2a --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlPlatform.java @@ -0,0 +1,25 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +/** + * The single seam between vanilla raw-GL and Angelica's GLStateManager. Every GL + * state change the glow pass makes goes through here so Angelica's state cache stays + * in sync (the same failure class handled in BatchingFontRendererMixin). + */ +public interface GlPlatform { + + void enable(int cap); + + void disable(int cap); + + boolean isEnabled(int cap); + + void blendFunc(int src, int dst); + + void color4f(float r, float g, float b, float a); + + void bindTexture2D(int textureId); + + void useProgram(int program); + + String name(); +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlPlatformFactory.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlPlatformFactory.java new file mode 100644 index 0000000..2232dc7 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlPlatformFactory.java @@ -0,0 +1,29 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class GlPlatformFactory { + + private static final Logger LOG = LogManager.getLogger("GlowGlPlatform"); + private static GlPlatform instance; + + private GlPlatformFactory() {} + + public static GlPlatform get() { + if (instance == null) { + instance = detect(); + LOG.info("Entity glow using GlPlatform: {}", instance.name()); + } + return instance; + } + + private static GlPlatform detect() { + try { + Class.forName("com.gtnewhorizons.angelica.glsm.GLStateManager"); + return new AngelicaGlPlatform(); + } catch (Throwable notPresent) { + return new VanillaGlPlatform(); + } + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlShaderProgram.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlShaderProgram.java new file mode 100644 index 0000000..d99b4af --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlShaderProgram.java @@ -0,0 +1,79 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Scanner; + +import net.minecraft.client.Minecraft; +import net.minecraft.util.ResourceLocation; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL20; + +/** Compiles and links a vertex+fragment program from resources. Immediate-mode friendly (GLSL 120). */ +public class GlShaderProgram { + + private static final Logger LOG = LogManager.getLogger("GlowShaderProgram"); + + private int program = 0; + private boolean valid = false; + private final Map uniforms = new HashMap<>(); + + public GlShaderProgram(ResourceLocation vsh, ResourceLocation fsh) { + try { + int v = compile(GL20.GL_VERTEX_SHADER, read(vsh)); + int f = compile(GL20.GL_FRAGMENT_SHADER, read(fsh)); + program = GL20.glCreateProgram(); + GL20.glAttachShader(program, v); + GL20.glAttachShader(program, f); + GL20.glLinkProgram(program); + if (GL20.glGetProgrami(program, GL20.GL_LINK_STATUS) == GL11.GL_FALSE) { + LOG.error("Glow program link failed: {}", GL20.glGetProgramInfoLog(program, 4096)); + GL20.glDeleteProgram(program); + } else { + valid = true; + } + GL20.glDeleteShader(v); + GL20.glDeleteShader(f); + } catch (Exception e) { + LOG.error("Failed to build glow shader program", e); + } + } + + private static String read(ResourceLocation loc) throws Exception { + InputStream in = Minecraft.getMinecraft() + .getResourceManager() + .getResource(loc) + .getInputStream(); + try (Scanner s = new Scanner(in, StandardCharsets.UTF_8.name())) { + return s.useDelimiter("\\A") + .next(); + } + } + + private static int compile(int type, String src) { + int shader = GL20.glCreateShader(type); + GL20.glShaderSource(shader, src); + GL20.glCompileShader(shader); + if (GL20.glGetShaderi(shader, GL20.GL_COMPILE_STATUS) == GL11.GL_FALSE) { + LOG.error("Glow shader compile failed: {}", GL20.glGetShaderInfoLog(shader, 4096)); + } + return shader; + } + + public int id() { + return program; + } + + public boolean valid() { + return valid; + } + + public int uniform(String name) { + return uniforms.computeIfAbsent(name, n -> GL20.glGetUniformLocation(program, n)); + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowOutlineSizing.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowOutlineSizing.java new file mode 100644 index 0000000..c1a16da --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowOutlineSizing.java @@ -0,0 +1,22 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +/** Converts user-facing screen-pixel measurements to the glow framebuffer's pixel grid. */ +final class GlowOutlineSizing { + + private static final float BLACK_STROKE_SCREEN_PIXELS = 1.5F; + private static final float BLACK_STROKE_FEATHER_SCREEN_PIXELS = 1.0F; + + private GlowOutlineSizing() {} + + static float framebufferPixels(int screenPixels, int framebufferScale) { + return Math.max(1, screenPixels) / (float) framebufferScale; + } + + static float blackStrokeFramebufferPixels(int framebufferScale) { + return BLACK_STROKE_SCREEN_PIXELS / framebufferScale; + } + + static float blackStrokeFeatherFramebufferPixels(int framebufferScale) { + return BLACK_STROKE_FEATHER_SCREEN_PIXELS / framebufferScale; + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowRenderCore.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowRenderCore.java new file mode 100644 index 0000000..4a0225a --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowRenderCore.java @@ -0,0 +1,274 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.OpenGlHelper; +import net.minecraft.client.renderer.entity.Render; +import net.minecraft.client.renderer.entity.RenderManager; +import net.minecraft.client.shader.Framebuffer; +import net.minecraft.entity.Entity; +import net.minecraft.util.ResourceLocation; +import net.minecraft.world.World; + +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL20; + +import io.github.cruciblemc.necrotempus.NecroTempusConfig; +import io.github.cruciblemc.necrotempus.modules.features.glow.GlowingEntityRegistry; +import io.github.cruciblemc.necrotempus.modules.features.glow.client.GlowClientManager; + +/** Renders glowing entities as flat silhouettes into a secondary framebuffer. */ +public class GlowRenderCore { + + public static final GlowRenderCore INSTANCE = new GlowRenderCore(); + + /** + * True while {@link #renderOutlines} is rendering entity silhouettes. + * Mixins (e.g. {@code RendererLivingEntity}) check this to skip nameplate + * rendering that would otherwise pollute the glow mask. + */ + public static boolean silhouettePassActive = false; + + private static final int DEFAULT_RGB = 0xFFFFFF; // 1.7.10 has no team colours; fallback constant + // Render the glow at 1/GLOW_SCALE resolution: the fullscreen outline shader is the cost, so fewer + // fragments is a big win, and the linear upscale on composite doubles as free anti-aliasing. + private static final int GLOW_SCALE = 2; + + private Framebuffer glowFbo; + private GlShaderProgram silhouette; + + private Framebuffer blurFbo; + private GlShaderProgram outline; + private GlShaderProgram blit; + + private GlowRenderCore() {} + + public Framebuffer glowFramebuffer() { + return glowFbo; + } + + private void lazyInit() { + if (silhouette == null) { + silhouette = new GlShaderProgram( + new ResourceLocation("necrotempus", "shaders/program/glow_silhouette.vsh"), + new ResourceLocation("necrotempus", "shaders/program/glow_silhouette.fsh")); + } + } + + private void lazyInitComposite() { + if (outline == null) { + outline = new GlShaderProgram( + new ResourceLocation("necrotempus", "shaders/program/glow_outline.vsh"), + new ResourceLocation("necrotempus", "shaders/program/glow_outline.fsh")); + } + if (blit == null) { + blit = new GlShaderProgram( + new ResourceLocation("necrotempus", "shaders/program/glow_outline.vsh"), + new ResourceLocation("necrotempus", "shaders/program/glow_blit.fsh")); + } + } + + /** Recreate the (downscaled) glow framebuffer if the main framebuffer size changed. */ + public void ensureSize() { + Framebuffer main = Minecraft.getMinecraft() + .getFramebuffer(); + int w = Math.max(1, main.framebufferWidth / GLOW_SCALE); + int h = Math.max(1, main.framebufferHeight / GLOW_SCALE); + if (glowFbo == null || glowFbo.framebufferWidth != w || glowFbo.framebufferHeight != h) { + if (glowFbo != null) glowFbo.deleteFramebuffer(); + glowFbo = new Framebuffer(w, h, true); // useDepth=true + glowFbo.setFramebufferColor(0.0F, 0.0F, 0.0F, 0.0F); + glowFbo.setFramebufferFilter(GL11.GL_LINEAR); // smooth when the outline shader samples it + } + } + + public void renderOutlines(float partialTicks) { + GlowingEntityRegistry registry = GlowClientManager.getInstance() + .registry(); + if (!NecroTempusConfig.enableEntityGlow || registry.isEmpty()) return; + + if (!OpenGlHelper.isFramebufferEnabled()) return; // FBOs unsupported -> disable gracefully + + Minecraft mc = Minecraft.getMinecraft(); + World world = mc.theWorld; + if (world == null) return; + + lazyInit(); + if (!silhouette.valid()) return; + ensureSize(); + + GlPlatform gl = GlPlatformFactory.get(); + + glowFbo.framebufferClear(); + glowFbo.bindFramebuffer(true); // true -> set the downscaled viewport for the silhouette render + + // Always render the full silhouette with depth test off, so the outline shows through walls + // (matching 1.9+ vanilla glow). + boolean prevDepth = gl.isEnabled(GL11.GL_DEPTH_TEST); + gl.disable(GL11.GL_DEPTH_TEST); + boolean prevLighting = gl.isEnabled(GL11.GL_LIGHTING); + boolean prevLight0 = gl.isEnabled(GL11.GL_LIGHT0); + boolean prevLight1 = gl.isEnabled(GL11.GL_LIGHT1); + boolean prevColorMaterial = gl.isEnabled(GL11.GL_COLOR_MATERIAL); + gl.disable(GL11.GL_LIGHTING); + gl.disable(GL11.GL_LIGHT0); + gl.disable(GL11.GL_LIGHT1); + gl.disable(GL11.GL_COLOR_MATERIAL); + gl.useProgram(silhouette.id()); + GL20.glUniform1i(silhouette.uniform("uTex"), 0); + + silhouettePassActive = true; + try { + for (int id : registry.glowingIds()) { + Entity e = world.getEntityByID(id); + if (e == null) continue; + Integer stored = registry.colorFor(id); + int rgb = (stored == null || stored < 0) ? DEFAULT_RGB : stored; + GL20.glUniform3f( + silhouette.uniform("uColor"), + ((rgb >> 16) & 0xFF) / 255.0F, + ((rgb >> 8) & 0xFF) / 255.0F, + (rgb & 0xFF) / 255.0F); + renderEntitySilhouette(e, partialTicks); + } + } finally { + silhouettePassActive = false; + } + + gl.useProgram(0); + if (prevDepth) gl.enable(GL11.GL_DEPTH_TEST); + else gl.disable(GL11.GL_DEPTH_TEST); + if (prevLighting) gl.enable(GL11.GL_LIGHTING); + if (prevLight0) gl.enable(GL11.GL_LIGHT0); + if (prevLight1) gl.enable(GL11.GL_LIGHT1); + if (prevColorMaterial) gl.enable(GL11.GL_COLOR_MATERIAL); + + // Restore the main framebuffer + full viewport so the rest of the frame draws normally. + mc.getFramebuffer() + .bindFramebuffer(true); + } + + /** + * Render only the entity's model via Render.doRender, deliberately NOT going through + * RenderManager.renderEntitySimple (which also calls doRenderShadowAndFire). That keeps the + * ground drop-shadow and fire overlay out of the silhouette mask, flag-independently — so it + * also suppresses the shadow under Angelica, whose shadow toggle is separate from fancyGraphics. + * Replicates renderEntityStatic's interpolated position/yaw; lightmap/brightness setup is skipped + * because the silhouette shader ignores lighting and texture colour anyway. + */ + private void renderEntitySilhouette(Entity e, float partialTicks) { + Render render = RenderManager.instance.getEntityRenderObject(e); + if (render == null) return; + double x = e.lastTickPosX + (e.posX - e.lastTickPosX) * partialTicks - RenderManager.renderPosX; + double y = e.lastTickPosY + (e.posY - e.lastTickPosY) * partialTicks - RenderManager.renderPosY; + double z = e.lastTickPosZ + (e.posZ - e.lastTickPosZ) * partialTicks - RenderManager.renderPosZ; + float yaw = e.prevRotationYaw + (e.rotationYaw - e.prevRotationYaw) * partialTicks; + render.doRender(e, x, y, z, yaw, partialTicks); + } + + private void ensureBlurSize() { + int w = glowFbo.framebufferWidth; + int h = glowFbo.framebufferHeight; + if (blurFbo == null || blurFbo.framebufferWidth != w || blurFbo.framebufferHeight != h) { + if (blurFbo != null) blurFbo.deleteFramebuffer(); + blurFbo = new Framebuffer(w, h, false); + blurFbo.setFramebufferColor(0.0F, 0.0F, 0.0F, 0.0F); + blurFbo.setFramebufferFilter(GL11.GL_LINEAR); // smooth upscale when blitting the ring to screen + } + } + + /** + * Turn the filled silhouette in glowFbo into an outline: one dilate-and-subtract pass writes + * the coloured ring into blurFbo, then blend that ring over the scene. glowFbo (the original + * mask) is left intact — the outline shader reads it. + */ + public void composite(float partialTicks) { + if (glowFbo == null) return; + lazyInitComposite(); + if (!outline.valid() || !blit.valid()) return; + ensureBlurSize(); + + GlPlatform gl = GlPlatformFactory.get(); + boolean prevBlend = gl.isEnabled(GL11.GL_BLEND); + boolean prevDepth = gl.isEnabled(GL11.GL_DEPTH_TEST); + boolean prevLighting = gl.isEnabled(GL11.GL_LIGHTING); + // The setting is expressed in final screen pixels. Convert it to the downscaled FBO grid so + // compositing does not multiply the outline (and its black edge) by GLOW_SCALE. + float width = GlowOutlineSizing.framebufferPixels(NecroTempusConfig.glowOutlineWidth, GLOW_SCALE); + float blackStrokeWidth = GlowOutlineSizing.blackStrokeFramebufferPixels(GLOW_SCALE); + float blackStrokeFeather = GlowOutlineSizing.blackStrokeFeatherFramebufferPixels(GLOW_SCALE); + float tw = 1.0F / glowFbo.framebufferWidth; + float th = 1.0F / glowFbo.framebufferHeight; + + gl.disable(GL11.GL_DEPTH_TEST); + gl.disable(GL11.GL_LIGHTING); + + // Outline pass: dilate glowFbo's silhouette by `width` px and subtract the interior -> ring. + blurFbo.framebufferClear(); + blurFbo.bindFramebuffer(true); // downscaled viewport for the outline pass + gl.useProgram(outline.id()); + GL20.glUniform1i(outline.uniform("uTex"), 0); + GL20.glUniform2f(outline.uniform("uTexel"), tw, th); + GL20.glUniform1f(outline.uniform("uWidth"), width); + GL20.glUniform1f(outline.uniform("uStrokeWidth"), blackStrokeWidth); + GL20.glUniform1f(outline.uniform("uStrokeFeather"), blackStrokeFeather); + GL20.glUniform1f(outline.uniform("uTime"), (float) (Minecraft.getSystemTime() / 1000.0)); + gl.bindTexture2D(glowFbo.framebufferTexture); + drawFullscreenQuad(gl); + + // Composite the outline ring over the main framebuffer (full viewport; the half-res ring is + // upscaled via GL_LINEAR filtering). Blit through a shader (not fixed-function) so it samples + // ONLY the ring texture, immune to leftover lightmap / texture-env / glColor state from world + // rendering (e.g. the chest tile-entity renderer) that would otherwise darken the outline. + Minecraft.getMinecraft() + .getFramebuffer() + .bindFramebuffer(true); + gl.enable(GL11.GL_BLEND); + gl.blendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + gl.useProgram(blit.id()); + GL20.glUniform1i(blit.uniform("uTex"), 0); + gl.bindTexture2D(blurFbo.framebufferTexture); + drawFullscreenQuad(gl); + gl.useProgram(0); + + if (prevDepth) gl.enable(GL11.GL_DEPTH_TEST); + else gl.disable(GL11.GL_DEPTH_TEST); + if (prevLighting) gl.enable(GL11.GL_LIGHTING); + else gl.disable(GL11.GL_LIGHTING); + if (!prevBlend) gl.disable(GL11.GL_BLEND); + } + + /** Entry point the mixin calls: outline pass then composite. */ + public void renderAndComposite(float partialTicks) { + renderOutlines(partialTicks); + GlowingEntityRegistry registry = GlowClientManager.getInstance() + .registry(); + if (NecroTempusConfig.enableEntityGlow && !registry.isEmpty()) { + composite(partialTicks); + } + } + + private void drawFullscreenQuad(GlPlatform gl) { + // Ortho fullscreen quad in NDC via immediate mode. + GL11.glMatrixMode(GL11.GL_PROJECTION); + GL11.glPushMatrix(); + GL11.glLoadIdentity(); + GL11.glMatrixMode(GL11.GL_MODELVIEW); + GL11.glPushMatrix(); + GL11.glLoadIdentity(); + gl.enable(GL11.GL_TEXTURE_2D); + GL11.glBegin(GL11.GL_QUADS); + GL11.glTexCoord2f(0, 0); + GL11.glVertex2f(-1, -1); + GL11.glTexCoord2f(1, 0); + GL11.glVertex2f(1, -1); + GL11.glTexCoord2f(1, 1); + GL11.glVertex2f(1, 1); + GL11.glTexCoord2f(0, 1); + GL11.glVertex2f(-1, 1); + GL11.glEnd(); + GL11.glMatrixMode(GL11.GL_PROJECTION); + GL11.glPopMatrix(); + GL11.glMatrixMode(GL11.GL_MODELVIEW); + GL11.glPopMatrix(); + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowRenderPolicy.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowRenderPolicy.java new file mode 100644 index 0000000..4ad0efc --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowRenderPolicy.java @@ -0,0 +1,15 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +/** Rendering decisions shared by the glow silhouette mixins and their tests. */ +public final class GlowRenderPolicy { + + private GlowRenderPolicy() {} + + /** + * Keeps vanilla invisibility in the regular scene, while allowing the glow pass to capture the + * entity's actual model as an outline source. + */ + public static boolean shouldRenderBaseModel(boolean invisible, boolean silhouettePassActive) { + return !invisible || silhouettePassActive; + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/VanillaGlPlatform.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/VanillaGlPlatform.java new file mode 100644 index 0000000..e93fa67 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/VanillaGlPlatform.java @@ -0,0 +1,48 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +import net.minecraft.client.renderer.OpenGlHelper; + +import org.lwjgl.opengl.GL11; + +public class VanillaGlPlatform implements GlPlatform { + + @Override + public void enable(int cap) { + GL11.glEnable(cap); + } + + @Override + public void disable(int cap) { + GL11.glDisable(cap); + } + + @Override + public boolean isEnabled(int cap) { + return GL11.glIsEnabled(cap); + } + + @Override + public void blendFunc(int src, int dst) { + GL11.glBlendFunc(src, dst); + } + + @Override + public void color4f(float r, float g, float b, float a) { + GL11.glColor4f(r, g, b, a); + } + + @Override + public void bindTexture2D(int textureId) { + GL11.glBindTexture(GL11.GL_TEXTURE_2D, textureId); + } + + @Override + public void useProgram(int program) { + OpenGlHelper.func_153161_d(program); // glUseProgram + } + + @Override + public String name() { + return "Vanilla"; + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glyphs/compat/angelica/FontProviderGlyph.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glyphs/compat/angelica/FontProviderGlyph.java new file mode 100644 index 0000000..3508ac5 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/features/glyphs/compat/angelica/FontProviderGlyph.java @@ -0,0 +1,108 @@ +package io.github.cruciblemc.necrotempus.modules.features.glyphs.compat.angelica; + +import net.minecraft.util.ResourceLocation; + +import com.gtnewhorizons.angelica.client.font.FontProvider; + +import io.github.cruciblemc.necrotempus.modules.features.glyphs.CustomGlyphs; +import io.github.cruciblemc.necrotempus.modules.features.glyphs.GlyphsRegistry; +import io.github.cruciblemc.necrotempus.modules.features.modernfonts.ModernFontEntry; +import io.github.cruciblemc.necrotempus.modules.features.modernfonts.ModernFontSupport; +import io.github.cruciblemc.necrotempus.utils.MathUtils; + +public class FontProviderGlyph implements FontProvider { + + public static final FontProviderGlyph INSTANCE = new FontProviderGlyph(); + + /** Cached scale factor set by BatchingFontRendererMixin before rendering a glyph character. */ + public static float cachedGlyphScale = 1.0F; + + private boolean lastGlyphCheckResult = false; + + private FontProviderGlyph() {} + + private static CustomGlyphs glyph(char chr) { + return GlyphsRegistry.getCandidate(chr); + } + + public boolean isLastGlyphCheckAvailable() { + return lastGlyphCheckResult; + } + + @Override + public char getRandomReplacement(char chr) { + return chr; + } + + @Override + public boolean isGlyphAvailable(char chr) { + lastGlyphCheckResult = glyph(chr) != null || ModernFontSupport.getCandidate(chr) != null; + return lastGlyphCheckResult; + } + + @Override + public float getUStart(char chr) { + return 0.0F; + } + + @Override + public float getVStart(char chr) { + return 0.0F; + } + + @Override + public float getXAdvance(char chr) { + final CustomGlyphs g = glyph(chr); + if (g != null) return g.getFinalCharacterWidth(); + final ModernFontEntry e = ModernFontSupport.getCandidate(chr); + if (e != null) return e.width + 1; + return 0; + } + + @Override + public float getGlyphW(char chr) { + final CustomGlyphs g = glyph(chr); + if (g != null) { + switch (g.getFitMode()) { + case CONTAINS: + return 9; + case VERTICALLY: + return (float) Math.ceil(MathUtils.calculateWidth(g.getWidth(), g.getHeight(), 9)); + default: + return g.getWidth(); + } + } + final ModernFontEntry e = ModernFontSupport.getCandidate(chr); + if (e != null) return e.width; + return 0; + } + + @Override + public float getUSize(char chr) { + return 1.0F; + } + + @Override + public float getVSize(char chr) { + return 1.0F; + } + + @Override + public float getShadowOffset() { + return 0.0F; + } + + @Override + public ResourceLocation getTexture(char chr) { + final CustomGlyphs g = glyph(chr); + if (g != null) return g.getResource(); + final ModernFontEntry e = ModernFontSupport.getCandidate(chr); + if (e != null) return e.location; + return null; + } + + @Override + public float getYScaleMultiplier() { + return cachedGlyphScale; + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/NecroTempusMixins.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/NecroTempusMixins.java index cb54256..fed8322 100644 --- a/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/NecroTempusMixins.java +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/NecroTempusMixins.java @@ -1,12 +1,10 @@ package io.github.cruciblemc.necrotempus.modules.mixin; -import org.jetbrains.annotations.NotNull; - import com.gtnewhorizon.gtnhmixins.builders.IMixins; import com.gtnewhorizon.gtnhmixins.builders.MixinBuilder; - import io.github.cruciblemc.necrotempus.NecroTempusConfig; import io.github.cruciblemc.necrotempus.modules.features.font.FontFeatureToggles; +import org.jetbrains.annotations.NotNull; public enum NecroTempusMixins implements IMixins { @@ -39,6 +37,15 @@ public enum NecroTempusMixins implements IMixins { .addRequiredMod(MixinTargetedMod.ANGELICA) .addClientMixins("angelica.BatchingFontRendererMixin")), + ENTITY_RENDERER((new MixinBuilder()).setApplyIf(() -> NecroTempusConfig.enableEntityGlow) + .addClientMixins("minecraft.EntityRendererMixin")), + + RENDERER_LIVING_ENTITY((new MixinBuilder()).setApplyIf(() -> NecroTempusConfig.enableEntityGlow) + .addClientMixins("minecraft.RendererLivingEntityMixin")), + + RENDER_HORSE((new MixinBuilder()).setApplyIf(() -> NecroTempusConfig.enableEntityGlow) + .addClientMixins("minecraft.RenderHorseMixin")), + CRAFT_BOSS_BAR((new MixinBuilder()).addRequiredMod(MixinTargetedMod.CRUCIBLE) .addServerMixins("bukkit.boss.CraftBossBar")), CRAFT_PLAYER((new MixinBuilder()).addRequiredMod(MixinTargetedMod.CRUCIBLE) diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/angelica/FontStrategistMixin.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/angelica/FontStrategistMixin.java new file mode 100644 index 0000000..59c0a22 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/angelica/FontStrategistMixin.java @@ -0,0 +1,32 @@ +package io.github.cruciblemc.necrotempus.modules.mixin.mixins.angelica; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.gtnewhorizons.angelica.client.font.BatchingFontRenderer; +import com.gtnewhorizons.angelica.client.font.FontProvider; +import com.gtnewhorizons.angelica.client.font.FontStrategist; + +import io.github.cruciblemc.necrotempus.NecroTempusConfig; +import io.github.cruciblemc.necrotempus.modules.features.glyphs.compat.angelica.FontProviderGlyph; + +@Mixin(value = FontStrategist.class, remap = false) +public class FontStrategistMixin { + + @Inject(method = "getFontProvider", at = @At("HEAD"), cancellable = true, remap = false) + private static void nt$getFontProvider(BatchingFontRenderer me, char chr, boolean customFontEnabled, + boolean forceUnicode, CallbackInfoReturnable cir) { + + if (!NecroTempusConfig.modernFonts) return; + + final FontProvider provider = FontProviderGlyph.INSTANCE; + + if (provider.isGlyphAvailable(chr)) { + cir.setReturnValue(provider); + } + + } + +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/minecraft/EntityRendererMixin.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/minecraft/EntityRendererMixin.java new file mode 100644 index 0000000..3d6d0ee --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/minecraft/EntityRendererMixin.java @@ -0,0 +1,29 @@ +package io.github.cruciblemc.necrotempus.modules.mixin.mixins.minecraft; + +import net.minecraft.client.renderer.EntityRenderer; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import io.github.cruciblemc.necrotempus.NecroTempusConfig; +import io.github.cruciblemc.necrotempus.modules.features.glow.render.GlowRenderCore; + +@Mixin(EntityRenderer.class) +public abstract class EntityRendererMixin { + + // Injected at the unconditional "hand" profiler section (right after ForgeHooksClient.dispatchRenderLast), + // where the world camera projection/modelview are still active — before the depth-buffer clear and the + // CONDITIONALLY-called renderHand that would otherwise overwrite them with first-person-hand transforms. + @Inject( + method = "renderWorld(FJ)V", + at = @At( + value = "INVOKE_STRING", + target = "Lnet/minecraft/profiler/Profiler;endStartSection(Ljava/lang/String;)V", + args = "ldc=hand")) + private void nt$renderEntityGlow(float partialTicks, long nanoTime, CallbackInfo ci) { + if (!NecroTempusConfig.enableEntityGlow) return; + GlowRenderCore.INSTANCE.renderAndComposite(partialTicks); + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/minecraft/RenderHorseMixin.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/minecraft/RenderHorseMixin.java new file mode 100644 index 0000000..cf2f2ac --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/minecraft/RenderHorseMixin.java @@ -0,0 +1,23 @@ +package io.github.cruciblemc.necrotempus.modules.mixin.mixins.minecraft; + +import net.minecraft.client.renderer.entity.RenderHorse; +import net.minecraft.entity.passive.EntityHorse; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import io.github.cruciblemc.necrotempus.modules.features.glow.render.GlowRenderCore; +import io.github.cruciblemc.necrotempus.modules.features.glow.render.GlowRenderPolicy; + +/** Applies the silhouette-pass invisibility policy to RenderHorse's override. */ +@Mixin(RenderHorse.class) +public abstract class RenderHorseMixin { + + @Redirect( + method = "renderModel(Lnet/minecraft/entity/passive/EntityHorse;FFFFFF)V", + at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/passive/EntityHorse;isInvisible()Z")) + private boolean nt$renderInvisibleHorseModel(EntityHorse entity) { + return !GlowRenderPolicy.shouldRenderBaseModel(entity.isInvisible(), GlowRenderCore.silhouettePassActive); + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/minecraft/RendererLivingEntityMixin.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/minecraft/RendererLivingEntityMixin.java new file mode 100644 index 0000000..8176cc2 --- /dev/null +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/mixins/minecraft/RendererLivingEntityMixin.java @@ -0,0 +1,40 @@ +package io.github.cruciblemc.necrotempus.modules.mixin.mixins.minecraft; + +import net.minecraft.client.renderer.entity.RendererLivingEntity; +import net.minecraft.entity.EntityLivingBase; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import io.github.cruciblemc.necrotempus.modules.features.glow.render.GlowRenderCore; +import io.github.cruciblemc.necrotempus.modules.features.glow.render.GlowRenderPolicy; + +@Mixin(RendererLivingEntity.class) +public abstract class RendererLivingEntityMixin { + + /** + * Suppress nametag rendering while the glow silhouette pass is active, so + * nameplate text does not get captured in the glow mask framebuffer. + */ + @Inject(method = "func_110813_b(Lnet/minecraft/entity/EntityLivingBase;)Z", at = @At("HEAD"), cancellable = true) + private void nt$suppressNameplate(EntityLivingBase entity, CallbackInfoReturnable ci) { + if (GlowRenderCore.silhouettePassActive) { + ci.setReturnValue(false); + } + } + + /** + * Vanilla skips the model for fully invisible entities. During the glow pass the model itself + * must be captured into the silhouette framebuffer, while the regular scene keeps vanilla's + * invisible/translucent behavior. + */ + @Redirect( + method = "renderModel(Lnet/minecraft/entity/EntityLivingBase;FFFFFF)V", + at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/EntityLivingBase;isInvisible()Z")) + private boolean nt$renderInvisibleModel(EntityLivingBase entity) { + return !GlowRenderPolicy.shouldRenderBaseModel(entity.isInvisible(), GlowRenderCore.silhouettePassActive); + } +} diff --git a/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/plugin/NecroTempusLateMixins.java b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/plugin/NecroTempusLateMixins.java index 75647e7..bc74e71 100644 --- a/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/plugin/NecroTempusLateMixins.java +++ b/src/main/java/io/github/cruciblemc/necrotempus/modules/mixin/plugin/NecroTempusLateMixins.java @@ -10,6 +10,7 @@ import com.gtnewhorizon.gtnhmixins.builders.IMixins; import io.github.cruciblemc.necrotempus.modules.mixin.NecroTempusMixins; +import org.jetbrains.annotations.NotNull; @LateMixin public class NecroTempusLateMixins implements ILateMixinLoader { diff --git a/src/main/java/io/github/cruciblemc/necrotempus/proxy/ClientProxy.java b/src/main/java/io/github/cruciblemc/necrotempus/proxy/ClientProxy.java index 1b57f96..36c0cb2 100644 --- a/src/main/java/io/github/cruciblemc/necrotempus/proxy/ClientProxy.java +++ b/src/main/java/io/github/cruciblemc/necrotempus/proxy/ClientProxy.java @@ -6,6 +6,7 @@ import io.github.cruciblemc.necrotempus.modules.features.actionbar.client.ActionBarRegistry; import io.github.cruciblemc.necrotempus.modules.features.bossbar.client.BossBarRegistry; import io.github.cruciblemc.necrotempus.modules.features.font.FontFeatureToggles; +import io.github.cruciblemc.necrotempus.modules.features.glow.client.GlowRegistry; import io.github.cruciblemc.necrotempus.modules.features.glyphs.GlyphsRegistry; import io.github.cruciblemc.necrotempus.modules.features.modernfonts.ModernFontSupport; import io.github.cruciblemc.necrotempus.modules.features.packet.NTClientPacketRegistry; @@ -32,6 +33,7 @@ public void postInit(FMLPostInitializationEvent event) { TitleRegistry.init(); ActionBarRegistry.init(); NTClientPacketRegistry.init(); + GlowRegistry.init(); } } diff --git a/src/main/java/io/github/cruciblemc/necrotempus/utils/ServerUtils.java b/src/main/java/io/github/cruciblemc/necrotempus/utils/ServerUtils.java index 593d48d..b88b67a 100644 --- a/src/main/java/io/github/cruciblemc/necrotempus/utils/ServerUtils.java +++ b/src/main/java/io/github/cruciblemc/necrotempus/utils/ServerUtils.java @@ -42,6 +42,7 @@ public static T lazyAccess(boolean accessible, String method, Object target, @SuppressWarnings("unchecked") public static T lazyAccess(boolean accessible, String method, Object target) { try { + Method mt = target.getClass() .getDeclaredMethod(method); if (!accessible) mt.setAccessible(true); diff --git a/src/main/resources/assets/necrotempus/shaders/program/glow_blit.fsh b/src/main/resources/assets/necrotempus/shaders/program/glow_blit.fsh new file mode 100644 index 0000000..046fb69 --- /dev/null +++ b/src/main/resources/assets/necrotempus/shaders/program/glow_blit.fsh @@ -0,0 +1,9 @@ +#version 120 +// Plain textured blit. Using a shader (rather than fixed-function texturing) means the composite +// samples ONLY the ring texture and ignores the lightmap texture unit, texture-env combine, and +// current glColor left over from world rendering (e.g. the chest tile-entity renderer) — which would +// otherwise darken the outline. +uniform sampler2D uTex; +void main() { + gl_FragColor = texture2D(uTex, gl_TexCoord[0].st); +} diff --git a/src/main/resources/assets/necrotempus/shaders/program/glow_outline.fsh b/src/main/resources/assets/necrotempus/shaders/program/glow_outline.fsh new file mode 100644 index 0000000..c84c2ac --- /dev/null +++ b/src/main/resources/assets/necrotempus/shaders/program/glow_outline.fsh @@ -0,0 +1,58 @@ +#version 120 +// Rainbow-cycling outline: the coloured ring shifts hue uniformly over time, ignoring the entity's +// original colour. A separate, fixed-width black stroke surrounds its outer edge. +uniform sampler2D uTex; +uniform vec2 uTexel; // 1/width, 1/height +uniform float uWidth; // outline thickness in this half-resolution framebuffer (0.5..2) +uniform float uStrokeWidth; // maximum black-stroke thickness (0.75 = 1.5 final screen pixels) +uniform float uStrokeFeather; // outer antialiasing width (0.5 = 1 final screen pixel) +uniform float uTime; // seconds, for animation + +vec3 hsl2rgb(float h, float s, float l) { + vec3 rgb = clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + return l + s * (rgb - 0.5) * (1.0 - abs(2.0 * l - 1.0)); +} + +void main() { + vec2 uv = gl_TexCoord[0].st; + float minDist = 1.0e9; + // Minimum alpha in a 1-pixel neighbourhood. If ANY pixel within 1px is empty + // (alpha < 0.5) we are within 1px of the silhouette edge, and the ring should + // NOT be cut — this pushes the ring 1px inside the entity, past the AA fringe. + float minAlpha1px = 1.0; + const int R = 5; // constant loop bound (GLSL 120); covers max uWidth plus the AA margin + for (int dx = -R; dx <= R; dx++) { + for (int dy = -R; dy <= R; dy++) { + vec4 s = texture2D(uTex, uv + vec2(float(dx), float(dy)) * uTexel); + if (s.a > 0.5) { + float d = length(vec2(float(dx), float(dy))); + if (d < minDist) minDist = d; + } + if (abs(dx) <= 1 && abs(dy) <= 1) { + minAlpha1px = min(minAlpha1px, s.a); + } + } + } + // 1 inside the ring radius, smoothly to 0 across the last pixel of the outer edge... + float ring = 1.0 - smoothstep(uWidth - 0.5, uWidth + 0.5, minDist); + // ...cut inside only where EVERY pixel in a 1px radius is solid silhouette (>0.6 alpha), + // i.e. we are more than 1px deep inside the entity. This avoids the anti-aliased fringe. + float edgeMask = 1.0 - smoothstep(0.4, 0.6, minAlpha1px); + ring *= edgeMask; + + // Single hue for the entire ring, cycling through the rainbow over time + vec3 rainbow = hsl2rgb(fract(uTime * 0.08), 1.0, 0.5); + + // Keep a solid coloured core and reserve only the outermost band for the black stroke. For very + // thin configured outlines, cap the stroke at half the ring so it cannot swallow the colour. + float strokeWidth = min(uStrokeWidth, uWidth * 0.5); + float strokeStart = uWidth - strokeWidth; + float stroke = smoothstep(strokeStart - uStrokeFeather, strokeStart + uStrokeFeather, minDist); + + // The old alpha came from `ring`, whose outermost samples are deliberately translucent. Give the + // black band full coverage through uWidth and fade only beyond it, so distant strokes stay solid. + float strokeAlpha = (1.0 - smoothstep(uWidth, uWidth + uStrokeFeather, minDist)) * edgeMask; + float finalAlpha = mix(ring, strokeAlpha, stroke); + if (finalAlpha <= 0.01) discard; + gl_FragColor = vec4(mix(rainbow, vec3(0.0), stroke), finalAlpha); +} diff --git a/src/main/resources/assets/necrotempus/shaders/program/glow_outline.vsh b/src/main/resources/assets/necrotempus/shaders/program/glow_outline.vsh new file mode 100644 index 0000000..a8fc97b --- /dev/null +++ b/src/main/resources/assets/necrotempus/shaders/program/glow_outline.vsh @@ -0,0 +1,5 @@ +#version 120 +void main() { + gl_Position = ftransform(); + gl_TexCoord[0] = gl_MultiTexCoord0; +} diff --git a/src/main/resources/assets/necrotempus/shaders/program/glow_silhouette.fsh b/src/main/resources/assets/necrotempus/shaders/program/glow_silhouette.fsh new file mode 100644 index 0000000..eb5c0fb --- /dev/null +++ b/src/main/resources/assets/necrotempus/shaders/program/glow_silhouette.fsh @@ -0,0 +1,8 @@ +#version 120 +uniform sampler2D uTex; +uniform vec3 uColor; +void main() { + float a = texture2D(uTex, gl_TexCoord[0].st).a; + if (a < 0.1) discard; // preserve alpha-cutout shape (capes, etc.) + gl_FragColor = vec4(uColor, 1.0); +} diff --git a/src/main/resources/assets/necrotempus/shaders/program/glow_silhouette.vsh b/src/main/resources/assets/necrotempus/shaders/program/glow_silhouette.vsh new file mode 100644 index 0000000..851d62f --- /dev/null +++ b/src/main/resources/assets/necrotempus/shaders/program/glow_silhouette.vsh @@ -0,0 +1,5 @@ +#version 120 +void main() { + gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; + gl_TexCoord[0] = gl_MultiTexCoord0; +} diff --git a/src/test/java/io/github/cruciblemc/necrotempus/HarnessSanityTest.java b/src/test/java/io/github/cruciblemc/necrotempus/HarnessSanityTest.java new file mode 100644 index 0000000..ced808a --- /dev/null +++ b/src/test/java/io/github/cruciblemc/necrotempus/HarnessSanityTest.java @@ -0,0 +1,13 @@ +package io.github.cruciblemc.necrotempus; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class HarnessSanityTest { + + @Test + void harnessRuns() { + assertEquals(2, 1 + 1); + } +} diff --git a/src/test/java/io/github/cruciblemc/necrotempus/modules/features/glow/GlowingEntityRegistryTest.java b/src/test/java/io/github/cruciblemc/necrotempus/modules/features/glow/GlowingEntityRegistryTest.java new file mode 100644 index 0000000..fb6af89 --- /dev/null +++ b/src/test/java/io/github/cruciblemc/necrotempus/modules/features/glow/GlowingEntityRegistryTest.java @@ -0,0 +1,74 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class GlowingEntityRegistryTest { + + @Test + void storesAndReturnsColor() { + GlowingEntityRegistry r = new GlowingEntityRegistry(); + r.set(7, 0xFF5555, 100); + assertEquals(Integer.valueOf(0xFF5555), r.colorFor(7)); + assertFalse(r.isEmpty()); + } + + @Test + void colorSentinelPreserved() { + GlowingEntityRegistry r = new GlowingEntityRegistry(); + r.set(7, -1, 100); + assertEquals(Integer.valueOf(-1), r.colorFor(7)); + } + + @Test + void unknownEntityHasNoColor() { + GlowingEntityRegistry r = new GlowingEntityRegistry(); + assertNull(r.colorFor(42)); + } + + @Test + void finiteEntryExpires() { + GlowingEntityRegistry r = new GlowingEntityRegistry(); + r.set(7, 0xFFFFFF, 2); + r.tick(); // 2 -> 1 + assertEquals(Integer.valueOf(0xFFFFFF), r.colorFor(7)); + r.tick(); // 1 -> 0, pruned + assertNull(r.colorFor(7)); + assertTrue(r.isEmpty()); + } + + @Test + void infiniteEntryNeverExpires() { + GlowingEntityRegistry r = new GlowingEntityRegistry(); + r.set(7, 0xFFFFFF, 0); // <= 0 => infinite + for (int i = 0; i < 1000; i++) r.tick(); + assertEquals(Integer.valueOf(0xFFFFFF), r.colorFor(7)); + } + + @Test + void removeAndClear() { + GlowingEntityRegistry r = new GlowingEntityRegistry(); + r.set(1, 0xFFFFFF, 100); + r.set(2, 0xFFFFFF, 100); + r.remove(1); + assertNull(r.colorFor(1)); + assertEquals(Integer.valueOf(0xFFFFFF), r.colorFor(2)); + r.clear(); + assertTrue(r.isEmpty()); + } + + @Test + void glowingIdsSnapshotIsStable() { + GlowingEntityRegistry r = new GlowingEntityRegistry(); + r.set(3, 0xFFFFFF, 100); + r.set(9, 0xFFFFFF, 100); + int[] ids = r.glowingIds(); + java.util.Arrays.sort(ids); + assertArrayEquals(new int[] { 3, 9 }, ids); + } +} diff --git a/src/test/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowOutlineSizingTest.java b/src/test/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowOutlineSizingTest.java new file mode 100644 index 0000000..90fcbf7 --- /dev/null +++ b/src/test/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowOutlineSizingTest.java @@ -0,0 +1,20 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class GlowOutlineSizingTest { + + @Test + void configuredWidthRemainsMeasuredInScreenPixelsWhenFramebufferIsDownscaled() { + assertEquals(0.5F, GlowOutlineSizing.framebufferPixels(1, 2)); + assertEquals(2.0F, GlowOutlineSizing.framebufferPixels(4, 2)); + } + + @Test + void blackStrokeUsesOneAndAHalfScreenPixelsWithOnePixelAntialiasing() { + assertEquals(0.75F, GlowOutlineSizing.blackStrokeFramebufferPixels(2)); + assertEquals(0.5F, GlowOutlineSizing.blackStrokeFeatherFramebufferPixels(2)); + } +} diff --git a/src/test/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowRenderPolicyTest.java b/src/test/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowRenderPolicyTest.java new file mode 100644 index 0000000..3e42662 --- /dev/null +++ b/src/test/java/io/github/cruciblemc/necrotempus/modules/features/glow/render/GlowRenderPolicyTest.java @@ -0,0 +1,21 @@ +package io.github.cruciblemc.necrotempus.modules.features.glow.render; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class GlowRenderPolicyTest { + + @Test + void invisibleEntityIsRenderedOnlyDuringSilhouettePass() { + assertFalse(GlowRenderPolicy.shouldRenderBaseModel(true, false)); + assertTrue(GlowRenderPolicy.shouldRenderBaseModel(true, true)); + } + + @Test + void visibleEntityKeepsRenderingOutsideAndInsideSilhouettePass() { + assertTrue(GlowRenderPolicy.shouldRenderBaseModel(false, false)); + assertTrue(GlowRenderPolicy.shouldRenderBaseModel(false, true)); + } +}