From 30ccc6ea5aa647dbfa86921772d40622e3b290cf Mon Sep 17 00:00:00 2001 From: froslanz Date: Thu, 22 Oct 2026 11:22:47 -0400 Subject: [PATCH] add custom font support for all client ui - new FrosFont renderer: loads ttf/otf from fros/fonts/, rasterizes text to cached textures, falls back to the vanilla font when unset - Font/FontSize settings in ClickGui (fontSize only shows when a font is set); missing files retry automatically every 2s - all gui buttons, panels and hud modules now render through it - handles section codes and styled components (watermark accent color) --- .../java/cc/fros/features/gui/Widget.java | 4 +- .../java/cc/fros/features/gui/items/Item.java | 4 +- .../gui/items/buttons/ColorButton.java | 6 +- .../modules/client/ClickGuiModule.java | 3 + .../features/modules/client/HudModule.java | 8 +- .../modules/hud/ArrayListHudModule.java | 26 +- .../modules/hud/CoordinatesHudModule.java | 8 +- .../features/modules/hud/FpsHudModule.java | 8 +- .../features/modules/hud/PingHudModule.java | 8 +- .../features/modules/hud/RadarHudModule.java | 8 +- .../features/modules/hud/TimeHudModule.java | 8 +- .../modules/hud/WatermarkHudModule.java | 8 +- .../modules/hud/WelcomerHudModule.java | 8 +- .../java/cc/fros/util/render/FrosFont.java | 364 ++++++++++++++++++ 14 files changed, 431 insertions(+), 40 deletions(-) create mode 100644 src/main/java/cc/fros/util/render/FrosFont.java diff --git a/src/main/java/cc/fros/features/gui/Widget.java b/src/main/java/cc/fros/features/gui/Widget.java index 9e0f5a3..58ac66a 100644 --- a/src/main/java/cc/fros/features/gui/Widget.java +++ b/src/main/java/cc/fros/features/gui/Widget.java @@ -1,5 +1,7 @@ package cc.fros.features.gui; +import cc.fros.util.render.FrosFont; + import cc.fros.features.Feature; import cc.fros.features.gui.items.Item; import cc.fros.features.gui.items.buttons.Button; @@ -193,6 +195,6 @@ public class Widget } protected void drawString(String text, double x, double y, int color) { - context.drawString(mc.font, text, (int) x, (int) y, color); + FrosFont.draw(context, text, (int) x, (int) y, color); } } \ No newline at end of file diff --git a/src/main/java/cc/fros/features/gui/items/Item.java b/src/main/java/cc/fros/features/gui/items/Item.java index fbf2c30..52d1832 100644 --- a/src/main/java/cc/fros/features/gui/items/Item.java +++ b/src/main/java/cc/fros/features/gui/items/Item.java @@ -1,5 +1,7 @@ package cc.fros.features.gui.items; +import cc.fros.util.render.FrosFont; + import cc.fros.features.Feature; import net.minecraft.client.gui.GuiGraphics; @@ -78,7 +80,7 @@ public class Item } protected void drawString(GuiGraphics context, String text, double x, double y, int color) { - context.drawString(mc.font, text, (int) x, (int) y, color); + FrosFont.draw(context, text, (int) x, (int) y, color); } public boolean isHovering(int mouseX, int mouseY) { diff --git a/src/main/java/cc/fros/features/gui/items/buttons/ColorButton.java b/src/main/java/cc/fros/features/gui/items/buttons/ColorButton.java index 7c4dc31..2bddd56 100644 --- a/src/main/java/cc/fros/features/gui/items/buttons/ColorButton.java +++ b/src/main/java/cc/fros/features/gui/items/buttons/ColorButton.java @@ -1,5 +1,7 @@ package cc.fros.features.gui.items.buttons; +import cc.fros.util.render.FrosFont; + import cc.fros.Fros; import cc.fros.features.gui.FrosGui; import cc.fros.features.gui.Widget; @@ -119,12 +121,12 @@ public class ColorButton extends Button { int buttonWidth = availableWidth / 2; RenderUtil.rect(context, pickerX, this.y + yOffset, pickerX + buttonWidth, this.y + yOffset + 14, hoveringCopy ? Fros.colorManager.getColorWithAlpha(y, ClickGuiModule.getInstance().topColor.getValue().getAlpha()) : 0x11555555); - drawString(context, "Copy", pickerX + buttonWidth / 2.0 - mc.font.width("Copy") / 2.0, this.y + yOffset + 3, -1); + drawString(context, "Copy", pickerX + buttonWidth / 2.0 - FrosFont.width("Copy") / 2.0, this.y + yOffset + 3, -1); hoveringCopy = isHoveringArea(mouseX, mouseY, pickerX, this.y + yOffset, pickerX + buttonWidth, this.y + yOffset + 14); RenderUtil.rect(context, pickerX + buttonWidth + 1, this.y + yOffset, pickerX + buttonWidth * 2 + 1, this.y + yOffset + 14, hoveringPaste ? Fros.colorManager.getColorWithAlpha(y, ClickGuiModule.getInstance().topColor.getValue().getAlpha()) : 0x11555555); - drawString(context, "Paste", pickerX + buttonWidth + buttonWidth / 2.0 - mc.font.width("Paste") / 2.0 + 1, this.y + yOffset + 3, -1); + drawString(context, "Paste", pickerX + buttonWidth + buttonWidth / 2.0 - FrosFont.width("Paste") / 2.0 + 1, this.y + yOffset + 3, -1); hoveringPaste = isHoveringArea(mouseX, mouseY, pickerX + buttonWidth + 1, this.y + yOffset, pickerX + buttonWidth * 2 + 1, this.y + yOffset + 14); } } diff --git a/src/main/java/cc/fros/features/modules/client/ClickGuiModule.java b/src/main/java/cc/fros/features/modules/client/ClickGuiModule.java index 0eb46cb..8ad0d18 100644 --- a/src/main/java/cc/fros/features/modules/client/ClickGuiModule.java +++ b/src/main/java/cc/fros/features/modules/client/ClickGuiModule.java @@ -16,6 +16,8 @@ import static cc.fros.features.commands.MessageSignatures.GENERAL; public class ClickGuiModule extends Module { private static ClickGuiModule INSTANCE; + public final Setting font = str("Font", ""); + public final Setting fontSize = num("FontSize", 10, 6, 24); public final Setting prefix = str("Prefix", "."); public final Setting color = color("Color", 0, 0, 255, 180); public final Setting topColor = color("TopColor", 0, 0, 150, 240); @@ -27,6 +29,7 @@ public class ClickGuiModule extends Module { public ClickGuiModule() { super("ClickGui", "Opens the ClickGui", Module.Category.CLIENT); setBind(GLFW.GLFW_KEY_RIGHT_SHIFT); + fontSize.setVisibility(v -> !font.getValue().isEmpty()); rainbowHue.setVisibility(v -> rainbow.getValue()); rainbowBrightness.setVisibility(v -> rainbow.getValue()); rainbowSaturation.setVisibility(v -> rainbow.getValue()); diff --git a/src/main/java/cc/fros/features/modules/client/HudModule.java b/src/main/java/cc/fros/features/modules/client/HudModule.java index 932bf92..ea2926e 100644 --- a/src/main/java/cc/fros/features/modules/client/HudModule.java +++ b/src/main/java/cc/fros/features/modules/client/HudModule.java @@ -1,5 +1,7 @@ package cc.fros.features.modules.client; +import cc.fros.util.render.FrosFont; + import cc.fros.Fros; import cc.fros.event.impl.input.MouseInputEvent; import cc.fros.event.impl.render.Render2DEvent; @@ -91,13 +93,13 @@ public abstract class HudModule extends Module { } if (shouldDrawDescription) { - int textWidth = mc.font.width(getName()); - int textHeight = mc.font.lineHeight; + int textWidth = FrosFont.width(getName()); + int textHeight = FrosFont.lineHeight(); float textX = x + width + 5; if (textX + textWidth > mc.getWindow().getGuiScaledWidth()) { textX = x - 5 - textWidth; } - e.getContext().drawString(mc.font, getName(), + FrosFont.draw(e.getContext(), getName(), (int) textX, (int) (y + height / 2f - textHeight / 2f), -1); HudEditorScreen.getInstance().anyHover = true; } diff --git a/src/main/java/cc/fros/features/modules/hud/ArrayListHudModule.java b/src/main/java/cc/fros/features/modules/hud/ArrayListHudModule.java index 44da058..e7d4370 100644 --- a/src/main/java/cc/fros/features/modules/hud/ArrayListHudModule.java +++ b/src/main/java/cc/fros/features/modules/hud/ArrayListHudModule.java @@ -1,5 +1,7 @@ package cc.fros.features.modules.hud; +import cc.fros.util.render.FrosFont; + import cc.fros.Fros; import cc.fros.event.impl.render.Render2DEvent; import cc.fros.features.modules.client.HudModule; @@ -38,20 +40,20 @@ public class ArrayListHudModule extends HudModule { float x = getX(); float y = getY(); - int lineHeight = mc.font.lineHeight; + int lineHeight = FrosFont.lineHeight(); int color = Fros.colorManager.getColorAsInt(); boolean right = alignment.getValue() == Align.RIGHT; int nameBlock = 0; int content = 0; for (Module module : shown) { - int nameWidth = mc.font.width(module.getDisplayName()); + int nameWidth = FrosFont.width(module.getDisplayName()); nameBlock = Math.max(nameBlock, nameWidth); int rowWidth = nameWidth; String info = module.getDisplayInfo(); if (info != null) { - rowWidth += mc.font.width(" ") + infoWidth(info); + rowWidth += FrosFont.width(" ") + infoWidth(info); } content = Math.max(content, rowWidth); } @@ -64,26 +66,26 @@ public class ArrayListHudModule extends HudModule { int rowY = (int) (y + i * lineHeight); int baseColor = on ? color : DISABLED_COLOR; - int nameWidth = mc.font.width(name); + int nameWidth = FrosFont.width(name); // name is anchored to the alignment side using names only, so it // never moves when dynamic module info appears or changes int nameX = (int) (right ? x + nameBlock - nameWidth : x); - e.getContext().drawString(mc.font, name, nameX, rowY, baseColor); + FrosFont.draw(e.getContext(), name, nameX, rowY, baseColor); if (info != null) { // info sits directly on the opposite side of the name, // separated by a single space instead of a gap int blockWidth = infoWidth(info); - int space = mc.font.width(" "); + int space = FrosFont.width(" "); int infoX = right ? nameX - space - blockWidth : nameX + nameWidth + space; int bracketColor = on ? BRACKET_COLOR : DISABLED_COLOR; - e.getContext().drawString(mc.font, "[", infoX, rowY, bracketColor); - infoX += mc.font.width("["); - e.getContext().drawString(mc.font, info, infoX, rowY, on ? -1 : DISABLED_COLOR); - infoX += mc.font.width(info); - e.getContext().drawString(mc.font, "]", infoX, rowY, bracketColor); + FrosFont.draw(e.getContext(), "[", infoX, rowY, bracketColor); + infoX += FrosFont.width("["); + FrosFont.draw(e.getContext(), info, infoX, rowY, on ? -1 : DISABLED_COLOR); + infoX += FrosFont.width(info); + FrosFont.draw(e.getContext(), "]", infoX, rowY, bracketColor); } } @@ -92,7 +94,7 @@ public class ArrayListHudModule extends HudModule { } private int infoWidth(String info) { - return mc.font.width("[") + mc.font.width(info) + mc.font.width("]"); + return FrosFont.width("[") + FrosFont.width(info) + FrosFont.width("]"); } private Comparator comparator() { diff --git a/src/main/java/cc/fros/features/modules/hud/CoordinatesHudModule.java b/src/main/java/cc/fros/features/modules/hud/CoordinatesHudModule.java index 0ab0026..14ea39a 100644 --- a/src/main/java/cc/fros/features/modules/hud/CoordinatesHudModule.java +++ b/src/main/java/cc/fros/features/modules/hud/CoordinatesHudModule.java @@ -1,5 +1,7 @@ package cc.fros.features.modules.hud; +import cc.fros.util.render.FrosFont; + import cc.fros.event.impl.render.Render2DEvent; import cc.fros.features.modules.client.HudModule; import cc.fros.features.settings.Setting; @@ -33,11 +35,11 @@ public class CoordinatesHudModule extends HudModule { } } - e.getContext().drawString(mc.font, coordsStr, + FrosFont.draw(e.getContext(), coordsStr, (int) getX(), (int) getY(), -1); - setWidth(mc.font.width(coordsStr)); - setHeight(mc.font.lineHeight); + setWidth(FrosFont.width(coordsStr)); + setHeight(FrosFont.lineHeight()); } } diff --git a/src/main/java/cc/fros/features/modules/hud/FpsHudModule.java b/src/main/java/cc/fros/features/modules/hud/FpsHudModule.java index 668877d..953e1fd 100644 --- a/src/main/java/cc/fros/features/modules/hud/FpsHudModule.java +++ b/src/main/java/cc/fros/features/modules/hud/FpsHudModule.java @@ -1,5 +1,7 @@ package cc.fros.features.modules.hud; +import cc.fros.util.render.FrosFont; + import cc.fros.event.impl.render.Render2DEvent; import cc.fros.features.modules.client.HudModule; import cc.fros.features.settings.Setting; @@ -18,10 +20,10 @@ public class FpsHudModule extends HudModule { int fps = mc.getFps(); String fpsStr = label.getValue() ? "FPS: " + fps : String.valueOf(fps); - e.getContext().drawString(mc.font, fpsStr, + FrosFont.draw(e.getContext(), fpsStr, (int) getX(), (int) getY(), -1); - setWidth(mc.font.width(fpsStr)); - setHeight(mc.font.lineHeight); + setWidth(FrosFont.width(fpsStr)); + setHeight(FrosFont.lineHeight()); } } diff --git a/src/main/java/cc/fros/features/modules/hud/PingHudModule.java b/src/main/java/cc/fros/features/modules/hud/PingHudModule.java index a41a134..eebe089 100644 --- a/src/main/java/cc/fros/features/modules/hud/PingHudModule.java +++ b/src/main/java/cc/fros/features/modules/hud/PingHudModule.java @@ -1,5 +1,7 @@ package cc.fros.features.modules.hud; +import cc.fros.util.render.FrosFont; + import cc.fros.event.impl.render.Render2DEvent; import cc.fros.features.modules.client.HudModule; import cc.fros.features.settings.Setting; @@ -36,11 +38,11 @@ public class PingHudModule extends HudModule { } private void draw(Render2DEvent e, String text, int color) { - e.getContext().drawString(mc.font, text, + FrosFont.draw(e.getContext(), text, (int) getX(), (int) getY(), color); - setWidth(mc.font.width(text)); - setHeight(mc.font.lineHeight); + setWidth(FrosFont.width(text)); + setHeight(FrosFont.lineHeight()); } private static int colorForPing(int ping) { diff --git a/src/main/java/cc/fros/features/modules/hud/RadarHudModule.java b/src/main/java/cc/fros/features/modules/hud/RadarHudModule.java index be78885..9df65f5 100644 --- a/src/main/java/cc/fros/features/modules/hud/RadarHudModule.java +++ b/src/main/java/cc/fros/features/modules/hud/RadarHudModule.java @@ -1,5 +1,7 @@ package cc.fros.features.modules.hud; +import cc.fros.util.render.FrosFont; + import cc.fros.Fros; import cc.fros.event.impl.render.Render2DEvent; import cc.fros.features.modules.client.HudModule; @@ -47,7 +49,7 @@ public class RadarHudModule extends HudModule { float x = getX(); float y = getY(); - int lineHeight = mc.font.lineHeight; + int lineHeight = FrosFont.lineHeight(); int widest = 0; for (int i = 0; i < nearby.size(); i++) { @@ -57,8 +59,8 @@ public class RadarHudModule extends HudModule { : entry.name(); int color = entry.friend() ? 0xFF55FF55 : -1; - e.getContext().drawString(mc.font, text, (int) x, (int) (y + i * lineHeight), color); - widest = Math.max(widest, mc.font.width(text)); + FrosFont.draw(e.getContext(), text, (int) x, (int) (y + i * lineHeight), color); + widest = Math.max(widest, FrosFont.width(text)); } setWidth(widest); diff --git a/src/main/java/cc/fros/features/modules/hud/TimeHudModule.java b/src/main/java/cc/fros/features/modules/hud/TimeHudModule.java index 654737f..13830fe 100644 --- a/src/main/java/cc/fros/features/modules/hud/TimeHudModule.java +++ b/src/main/java/cc/fros/features/modules/hud/TimeHudModule.java @@ -1,5 +1,7 @@ package cc.fros.features.modules.hud; +import cc.fros.util.render.FrosFont; + import cc.fros.event.impl.render.Render2DEvent; import cc.fros.features.modules.client.HudModule; import cc.fros.features.settings.Setting; @@ -24,10 +26,10 @@ public class TimeHudModule extends HudModule { DateTimeFormatter formatter = twentyFourHour.getValue() ? TWENTY_FOUR_HOUR : TWELVE_HOUR; String timeStr = LocalTime.now().format(formatter); - e.getContext().drawString(mc.font, timeStr, + FrosFont.draw(e.getContext(), timeStr, (int) getX(), (int) getY(), -1); - setWidth(mc.font.width(timeStr)); - setHeight(mc.font.lineHeight); + setWidth(FrosFont.width(timeStr)); + setHeight(FrosFont.lineHeight()); } } diff --git a/src/main/java/cc/fros/features/modules/hud/WatermarkHudModule.java b/src/main/java/cc/fros/features/modules/hud/WatermarkHudModule.java index fc18f66..d8b6c24 100644 --- a/src/main/java/cc/fros/features/modules/hud/WatermarkHudModule.java +++ b/src/main/java/cc/fros/features/modules/hud/WatermarkHudModule.java @@ -1,5 +1,7 @@ package cc.fros.features.modules.hud; +import cc.fros.util.render.FrosFont; + import cc.fros.event.impl.render.Render2DEvent; import cc.fros.features.modules.client.HudModule; import cc.fros.features.settings.Setting; @@ -27,10 +29,10 @@ public class WatermarkHudModule extends HudModule { } Component component = TextUtil.text(watermarkString, text.getValue(), BuildConfig.VERSION); - e.getContext().drawString(mc.font, component, (int) getX(), (int) getY(), -1); + FrosFont.draw(e.getContext(), component, (int) getX(), (int) getY(), -1); - setWidth(mc.font.width(component)); - setHeight(mc.font.lineHeight); + setWidth(FrosFont.width(component)); + setHeight(FrosFont.lineHeight()); } } diff --git a/src/main/java/cc/fros/features/modules/hud/WelcomerHudModule.java b/src/main/java/cc/fros/features/modules/hud/WelcomerHudModule.java index de1e5f3..7014621 100644 --- a/src/main/java/cc/fros/features/modules/hud/WelcomerHudModule.java +++ b/src/main/java/cc/fros/features/modules/hud/WelcomerHudModule.java @@ -1,5 +1,7 @@ package cc.fros.features.modules.hud; +import cc.fros.util.render.FrosFont; + import cc.fros.event.impl.render.Render2DEvent; import cc.fros.features.modules.client.HudModule; import cc.fros.features.settings.Setting; @@ -17,10 +19,10 @@ public class WelcomerHudModule extends HudModule { String welcomeStr = greeting.getValue() + ", " + mc.getUser().getName(); - e.getContext().drawString(mc.font, welcomeStr, + FrosFont.draw(e.getContext(), welcomeStr, (int) getX(), (int) getY(), -1); - setWidth(mc.font.width(welcomeStr)); - setHeight(mc.font.lineHeight); + setWidth(FrosFont.width(welcomeStr)); + setHeight(FrosFont.lineHeight()); } } diff --git a/src/main/java/cc/fros/util/render/FrosFont.java b/src/main/java/cc/fros/util/render/FrosFont.java new file mode 100644 index 0000000..4dd1357 --- /dev/null +++ b/src/main/java/cc/fros/util/render/FrosFont.java @@ -0,0 +1,364 @@ +package cc.fros.util.render; + +import cc.fros.features.modules.client.ClickGuiModule; +import com.mojang.blaze3d.platform.NativeImage; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; +import net.minecraft.resources.Identifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import static cc.fros.util.traits.Util.mc; + +/** + * Renders client UI text with a user supplied ttf/otf font. + * Text is rasterized with AWT and cached as textures; when no font + * is configured everything falls through to the vanilla font. + */ +public final class FrosFont { + private static final Logger LOGGER = LoggerFactory.getLogger("FrosFont"); + private static final Path FONT_DIR = FabricLoader.getInstance().getGameDir().resolve("fros").resolve("fonts"); + private static final int MAX_CACHE = 400; + + private static final int[] PALETTE = { + 0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, + 0xAA0000, 0xAA00AA, 0xFFAA00, 0xAAAAAA, + 0x555555, 0x5555FF, 0x55FF55, 0x55FFFF, + 0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF + }; + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private static final Map GLYPHS = new LinkedHashMap<>(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + if (size() <= MAX_CACHE) return false; + mc.getTextureManager().release(eldest.getValue().id()); + return true; + } + }; + + private record Key(int color, String text) { + } + + private record Glyph(Identifier id, int width, int height) { + } + + private static java.awt.Font awtFont; + private static FontMetrics metrics; + private static String loadedName; + private static float loadedSize = Float.NaN; + private static long lastAttempt; + private static int glyphCounter; + + private FrosFont() { + } + + public static int width(String text) { + if (text == null || text.isEmpty()) return 0; + sync(); + if (awtFont == null) return mc.font.width(text); + return metrics.stringWidth(stripCodes(text)); + } + + public static int width(Component component) { + if (component == null) return 0; + sync(); + if (awtFont == null) return mc.font.width(component); + return width(toCodeString(component)); + } + + public static int lineHeight() { + sync(); + return awtFont == null ? mc.font.lineHeight : metrics.getHeight(); + } + + public static void draw(GuiGraphics context, String text, int x, int y, int color) { + draw(context, text, x, y, color, false); + } + + public static void draw(GuiGraphics context, String text, int x, int y, int color, boolean shadow) { + if (text == null || text.isEmpty()) return; + sync(); + if (awtFont == null) { + context.drawString(mc.font, text, x, y, color, shadow); + return; + } + if (shadow) drawGlyphs(context, text, x + 1, y + 1, shadowColor(color)); + drawGlyphs(context, text, x, y, color); + } + + public static void draw(GuiGraphics context, Component component, int x, int y, int color) { + if (component == null) return; + sync(); + if (awtFont == null) { + context.drawString(mc.font, component, x, y, color); + return; + } + draw(context, toCodeString(component), x, y, color, false); + } + + // --- loading --- + + private static void sync() { + ClickGuiModule gui = ClickGuiModule.getInstance(); + String name = gui == null ? "" : gui.font.getValue(); + int size = gui == null ? 10 : gui.fontSize.getValue(); + if (name == null) name = ""; + + boolean changed = !Objects.equals(name, loadedName) || size != loadedSize; + if (!changed) { + boolean missing = awtFont == null && !name.isBlank(); + // retry a missing file every couple of seconds so dropping one + // into the folder loads without touching the setting again + if (!missing || System.currentTimeMillis() - lastAttempt < 2000) return; + } + reset(name, size, changed); + } + + private static void reset(String name, float size, boolean verbose) { + loadedName = name; + loadedSize = size; + lastAttempt = System.currentTimeMillis(); + awtFont = null; + metrics = null; + clearGlyphs(); + + try { + Files.createDirectories(FONT_DIR); + } catch (IOException e) { + LOGGER.error("Could not create font directory {}", FONT_DIR, e); + return; + } + if (name.isBlank()) return; + try { + Path file = resolveFontFile(name); + if (file == null) { + if (verbose) LOGGER.warn("Font '{}' not found in {}", name, FONT_DIR); + return; + } + awtFont = java.awt.Font.createFont(java.awt.Font.TRUETYPE_FONT, file.toFile()) + .deriveFont(java.awt.Font.PLAIN, size); + BufferedImage probe = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = probe.createGraphics(); + graphics.setFont(awtFont); + metrics = graphics.getFontMetrics(); + graphics.dispose(); + if (verbose) LOGGER.info("Loaded font {} ({}pt)", file.getFileName(), size); + } catch (Exception e) { + LOGGER.error("Failed to load font '{}'", name, e); + awtFont = null; + metrics = null; + } + } + + private static Path resolveFontFile(String name) { + try { + Files.createDirectories(FONT_DIR); + } catch (IOException e) { + LOGGER.error("Could not create font directory {}", FONT_DIR, e); + return null; + } + + String lower = name.toLowerCase(Locale.ROOT); + if (lower.endsWith(".ttf") || lower.endsWith(".otf")) { + Path direct = FONT_DIR.resolve(name); + return Files.isRegularFile(direct) ? direct : null; + } + + try (Stream files = Files.list(FONT_DIR)) { + return files.filter(Files::isRegularFile) + .filter(path -> { + String fileName = path.getFileName().toString().toLowerCase(Locale.ROOT); + return fileName.equals(lower) + || fileName.equals(lower + ".ttf") + || fileName.equals(lower + ".otf"); + }) + .findFirst() + .orElse(null); + } catch (IOException e) { + LOGGER.error("Could not list font directory {}", FONT_DIR, e); + return null; + } + } + + private static void clearGlyphs() { + for (Glyph glyph : new ArrayList<>(GLYPHS.values())) { + mc.getTextureManager().release(glyph.id()); + } + GLYPHS.clear(); + } + + // --- rasterizing --- + + private static void drawGlyphs(GuiGraphics context, String text, int x, int y, int color) { + Key key = new Key(color, text); + Glyph glyph = GLYPHS.get(key); + if (glyph == null) { + glyph = createGlyph(text, color); + if (glyph == null) return; + GLYPHS.put(key, glyph); + } + context.blit(RenderPipelines.GUI_TEXTURED, glyph.id(), + x - 1, y - 1, 0.0f, 0.0f, glyph.width(), glyph.height(), glyph.width(), glyph.height()); + } + + private static Glyph createGlyph(String text, int baseColor) { + String plain = stripCodes(text); + if (plain.isEmpty()) return null; + + int width = Math.max(1, metrics.stringWidth(plain)); + int height = metrics.getHeight(); + int imageWidth = width + 2; + int imageHeight = height + 2; + + BufferedImage image = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = image.createGraphics(); + graphics.setFont(awtFont); + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + drawSegments(graphics, text, baseColor); + graphics.dispose(); + + NativeImage nativeImage = new NativeImage(imageWidth, imageHeight, false); + for (int py = 0; py < imageHeight; py++) { + for (int px = 0; px < imageWidth; px++) { + int argb = image.getRGB(px, py); + int abgr = (argb & 0xFF000000) + | ((argb & 0x000000FF) << 16) + | (argb & 0x0000FF00) + | ((argb & 0x00FF0000) >> 16); + nativeImage.setPixelABGR(px, py, abgr); + } + } + + Identifier id = Identifier.fromNamespaceAndPath("fros", "font/glyph_" + glyphCounter++); + DynamicTexture texture = new DynamicTexture(() -> id.toString(), nativeImage); + texture.upload(); + mc.getTextureManager().register(id, texture); + return new Glyph(id, imageWidth, imageHeight); + } + + private static void drawSegments(Graphics2D graphics, String text, int baseColor) { + int baseline = metrics.getAscent() + 1; + int cursor = 1; + int current = baseColor; + int segmentStart = 0; + int i = 0; + + while (i < text.length()) { + if (text.charAt(i) == '\u00A7' && i + 1 < text.length()) { + cursor = flush(graphics, text, segmentStart, i, cursor, current, baseline); + + char code = Character.toLowerCase(text.charAt(i + 1)); + i += 2; + segmentStart = i; + + if (code == 'x') { + int rgb = 0; + int nibbles = 0; + while (nibbles < 6 && i + 1 < text.length() && text.charAt(i) == '\u00A7') { + int digit = Character.digit(Character.toLowerCase(text.charAt(i + 1)), 16); + if (digit < 0) break; + rgb = (rgb << 4) | digit; + i += 2; + nibbles++; + } + current = 0xFF000000 | rgb; + segmentStart = i; + } else if (code == 'r') { + current = baseColor; + } else { + int index = Character.digit(code, 16); + if (index >= 0) current = 0xFF000000 | PALETTE[index]; + } + continue; + } + i++; + } + flush(graphics, text, segmentStart, text.length(), cursor, current, baseline); + } + + private static int flush(Graphics2D graphics, String text, int start, int end, int cursor, int color, int baseline) { + if (end <= start) return cursor; + String segment = text.substring(start, end); + graphics.setColor(new Color(color, true)); + graphics.drawString(segment, cursor, baseline); + return cursor + metrics.stringWidth(segment); + } + + // --- helpers --- + + private static String stripCodes(String text) { + if (text.indexOf('\u00A7') < 0) return text; + StringBuilder builder = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\u00A7' && i + 1 < text.length()) { + i++; + continue; + } + builder.append(c); + } + return builder.toString(); + } + + private static String toCodeString(Component component) { + StringBuilder builder = new StringBuilder(); + TextColor[] previous = {null}; + + component.visit((style, text) -> { + TextColor color = style.getColor(); + if (Objects.equals(color, previous[0])) { + builder.append(text); + return Optional.empty(); + } + if (color == null) { + builder.append('\u00A7').append('r'); + } else { + appendColorCode(builder, color.getValue()); + } + previous[0] = color; + builder.append(text); + return Optional.empty(); + }, Style.EMPTY); + + return builder.toString(); + } + + private static void appendColorCode(StringBuilder builder, int rgb) { + for (int i = 0; i < PALETTE.length; i++) { + if (PALETTE[i] == (rgb & 0xFFFFFF)) { + builder.append('\u00A7').append(HEX[i]); + return; + } + } + builder.append('\u00A7').append('x'); + for (int i = 5; i >= 0; i--) { + builder.append('\u00A7').append(HEX[(rgb >> (i * 4)) & 0xF]); + } + } + + private static int shadowColor(int color) { + return ((color & 0xFCFCFC) >> 2) | (color & 0xFF000000); + } +}