diff --git a/.gitignore b/.gitignore index 6b3ef1c..ba17279 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ Thumbs.db # Backups *.bak +resources/assets/edenmod/font/emotes.json +resources/assets/edenmod/emotes_manifest.json diff --git a/build.gradle b/build.gradle index 6e198a0..3875ada 100644 --- a/build.gradle +++ b/build.gradle @@ -63,6 +63,73 @@ processResources { } } +ext.emoteFontId = 'edenmod:emotes' +ext.emoteCodepointBase = 0xF000 +ext.emoteDirs = ['catplush'] +ext.emoteAscent = 7 +ext.emoteHeight = 8 + +tasks.register('generateEmoteAssets') { + def assetsDir = file('resources/assets/edenmod') + def texturesDir = new File(assetsDir, 'textures') + def fontFile = new File(assetsDir, 'font/emotes.json') + def manifestFile = new File(assetsDir, 'emotes_manifest.json') + + inputs.files(fileTree(texturesDir) { include '**/*.png' }).skipWhenEmpty(false) + outputs.files(fontFile, manifestFile) + + doLast { + def images = [] + emoteDirs.each { dirName -> + def dir = new File(texturesDir, dirName) + if (dir.directory) { + dir.listFiles({ f -> f.isFile() && f.name.toLowerCase().endsWith('.png') } as FileFilter)?.each { + images << [dir: dirName, file: it] + } + } + } + // Natural sort so catplush2 sorts before catplush10. + images.sort { a, b -> + def numOf = { java.io.File f -> + def m = (f.name =~ /(\d+)/) + m.find() ? m.group(1).toInteger() : 0 + } + def na = numOf(a.file) + def nb = numOf(b.file) + na != nb ? na <=> nb : a.file.name <=> b.file.name + } + + def providers = [] + def emotes = [:] + images.eachWithIndex { img, idx -> + def name = img.file.name + def shortcode = name.take(name.lastIndexOf('.')) + int codepoint = emoteCodepointBase + idx + // "file" is resolved under assets//textures/, so it must + // NOT repeat the "textures" segment. + providers << [ + type : 'bitmap', + file : "edenmod:${img.dir}/${name}".toString(), + ascent: emoteAscent, + height: emoteHeight, + chars : [new String(Character.toChars(codepoint))] + ] + emotes[shortcode] = codepoint + } + + fontFile.parentFile.mkdirs() + fontFile.setText(groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([providers: providers])), 'UTF-8') + manifestFile.setText(groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([font: emoteFontId, emotes: emotes])), 'UTF-8') + + logger.lifecycle("EdenMod: generated ${images.size()} chat emote(s)" + (images.isEmpty() ? '' : ": " + emotes.keySet().join(', '))) + } +} + +processResources.dependsOn generateEmoteAssets +tasks.named('sourcesJar').configure { + dependsOn 'generateEmoteAssets' +} + jar { from('LICENSE') { rename { "${it}_${project.archivesBaseName}" } diff --git a/resources/assets/edenmod/textures/catplush/catplush.png b/resources/assets/edenmod/textures/catplush/catplush.png new file mode 100644 index 0000000..e0f1ee5 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush10.png b/resources/assets/edenmod/textures/catplush/catplush10.png new file mode 100644 index 0000000..56e281c Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush10.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush11.png b/resources/assets/edenmod/textures/catplush/catplush11.png new file mode 100644 index 0000000..65566e3 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush11.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush110.png b/resources/assets/edenmod/textures/catplush/catplush110.png new file mode 100644 index 0000000..cea25b2 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush110.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush12.png b/resources/assets/edenmod/textures/catplush/catplush12.png new file mode 100644 index 0000000..3bf65e2 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush12.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush13.png b/resources/assets/edenmod/textures/catplush/catplush13.png new file mode 100644 index 0000000..0c7d9d2 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush13.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush14.png b/resources/assets/edenmod/textures/catplush/catplush14.png new file mode 100644 index 0000000..b605411 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush14.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush15.png b/resources/assets/edenmod/textures/catplush/catplush15.png new file mode 100644 index 0000000..4bbc722 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush15.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush16.png b/resources/assets/edenmod/textures/catplush/catplush16.png new file mode 100644 index 0000000..8ab89c5 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush16.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush17.png b/resources/assets/edenmod/textures/catplush/catplush17.png new file mode 100644 index 0000000..f16ffdd Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush17.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush18.png b/resources/assets/edenmod/textures/catplush/catplush18.png new file mode 100644 index 0000000..1251acd Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush18.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush19.png b/resources/assets/edenmod/textures/catplush/catplush19.png new file mode 100644 index 0000000..7127af0 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush19.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush2.png b/resources/assets/edenmod/textures/catplush/catplush2.png new file mode 100644 index 0000000..95e99e5 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush2.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush20.png b/resources/assets/edenmod/textures/catplush/catplush20.png new file mode 100644 index 0000000..9e167ce Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush20.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush3.png b/resources/assets/edenmod/textures/catplush/catplush3.png new file mode 100644 index 0000000..c651b8e Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush3.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush4.png b/resources/assets/edenmod/textures/catplush/catplush4.png new file mode 100644 index 0000000..f51fe05 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush4.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush5.png b/resources/assets/edenmod/textures/catplush/catplush5.png new file mode 100644 index 0000000..a5a073b Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush5.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush6.png b/resources/assets/edenmod/textures/catplush/catplush6.png new file mode 100644 index 0000000..164fdf0 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush6.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush7.png b/resources/assets/edenmod/textures/catplush/catplush7.png new file mode 100644 index 0000000..3f06385 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush7.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush8.png b/resources/assets/edenmod/textures/catplush/catplush8.png new file mode 100644 index 0000000..40bf8ac Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush8.png differ diff --git a/resources/assets/edenmod/textures/catplush/catplush9.png b/resources/assets/edenmod/textures/catplush/catplush9.png new file mode 100644 index 0000000..44b2a31 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/catplush9.png differ diff --git a/src/tel/eden/mod/chat/DiscordChatFormatter.java b/src/tel/eden/mod/chat/DiscordChatFormatter.java index 7ce995e..dcbb0f3 100644 --- a/src/tel/eden/mod/chat/DiscordChatFormatter.java +++ b/src/tel/eden/mod/chat/DiscordChatFormatter.java @@ -32,7 +32,7 @@ public final class DiscordChatFormatter { private static final FontDescription PILL_FONT = new FontDescription.Resource(Identifier.parse("banner/pill")); private static final FontDescription PREFIX_FONT = new FontDescription.Resource(Identifier.parse("chat/prefix")); - private static final Style PREFIX_STYLE = Style.EMPTY.withFont(PREFIX_FONT).withColor(ChatFormatting.GREEN); + private static final Style PREFIX_STYLE = Style.EMPTY.withFont(PREFIX_FONT).withColor(ChatFormatting.AQUA); // banner/pill font: left cap, lowercase letters from U+E030, fill spacer, right cap. private static final int PILL_LEFT_CAP = 0xE060; @@ -45,8 +45,13 @@ public final class DiscordChatFormatter { private static final int[] SHIELD = {0xCFFFC, 0xE006, 0xCFFFF, 0xE002, 0xCFFFE}; private static final int[] CONTINUATION = {0xCFFFC, 0xE001, 0xD0006}; - // Bare http(s) links in relayed Discord messages become clickable in-game. - private static final Pattern URL_PATTERN = Pattern.compile("https?://\\S+"); + // Bare http(s) links become clickable, and :shortcode: tokens matching a + // known emote (see EmoteRegistry) become an inline image, in a single pass + // over relayed message content so both interleave correctly with plain text. + private static final Pattern TOKEN_PATTERN = Pattern.compile("(?https?://\\S+)|:(?[a-zA-Z0-9_+\\-]{2,32}):"); + + // Matches any :shortcode: token (shared with linkify). + private static final Pattern EMOTE_PATTERN = Pattern.compile(":(?[a-zA-Z0-9_+\\-]{2,32}):"); // Wynncraft chat line width: floor(chatWidth * 280 + 40), then divided by the // chat scale (mirrors ChatComponent's internal wrap width). @@ -187,17 +192,30 @@ private static MutableComponent replyTarget(String replyTo, String replyExcerpt) return segment; } - /** Render message text with any http(s) URLs as clickable, underlined aqua links. */ + /** Render text with http(s) URLs as clickable, underlined aqua links. */ + /** + * Render message text with any http(s) URLs as clickable, underlined aqua links, + * and any {@code :shortcode:} token matching a known emote (see + * {@link EmoteRegistry}) as an inline image. Unknown shortcodes are left as + * plain literal text (e.g. {@code :notarealemote:} stays exactly that), so a + * message never loses information just because an emote isn't recognized. + */ private static MutableComponent linkify(String content) { MutableComponent out = Component.empty(); - Matcher matcher = URL_PATTERN.matcher(content); + Matcher matcher = TOKEN_PATTERN.matcher(content); int last = 0; while (matcher.find()) { if (matcher.start() > last) { out.append(Component.literal(content.substring(last, matcher.start())).withStyle(ChatFormatting.GREEN)); } - String url = matcher.group(); - out.append(Component.literal(url).withStyle(linkStyle(url))); + String url = matcher.group("url"); + if (url != null) { + out.append(Component.literal(url).withStyle(linkStyle(url))); + } else { + String shortcode = matcher.group("emote"); + Component emote = emoteComponent(shortcode); + out.append(emote != null ? emote : Component.literal(matcher.group()).withStyle(ChatFormatting.GREEN)); + } last = matcher.end(); } if (last < content.length()) { @@ -206,16 +224,69 @@ private static MutableComponent linkify(String content) { return out; } + /** The inline glyph for {@code shortcode}, hoverable to show its name, or {@code null} if unknown. */ + private static Component emoteComponent(String shortcode) { + Integer codepoint = EmoteRegistry.codepointFor(shortcode); + if (codepoint == null) { + return null; + } + String glyph = new String(Character.toChars(codepoint)); + // WHITE is deliberate: Minecraft tints bitmap-font glyphs by the current + // text color, and these are full-color images, not glyph masks — white + // leaves the PNG's own colors untouched instead of dyeing it chat-green. + Style style = Style.EMPTY.withFont(EmoteRegistry.font()).withColor(ChatFormatting.WHITE).withHoverEvent(new HoverEvent.ShowText(Component.literal(":" + shortcode + ":").withStyle(ChatFormatting.GRAY))); + return Component.literal(glyph).withStyle(style); + } + private static Style linkStyle(String url) { Style base = Style.EMPTY.withColor(ChatFormatting.AQUA).withUnderlined(true); try { return base.withClickEvent(new ClickEvent.OpenUrl(URI.create(url))).withHoverEvent(new HoverEvent.ShowText(Component.literal("Open " + url))); } catch (IllegalArgumentException e) { - // Not a valid URI after all — show it plainly rather than as a dead link. return Style.EMPTY.withColor(ChatFormatting.GREEN); } } + /** + * Process any chat component for {@code :shortcode:} patterns and replace + * known emotes with inline image glyphs via {@link EmoteRegistry}, preserving + * per-element styles (Wynncraft rank tags, colours, etc.). Unknown shortcodes + * are left as literal text. Returns the original component unchanged if no + * emote pattern is found. + */ + public static Component processEmotes(Component message) { + String text = message.getString(); + if (!EMOTE_PATTERN.matcher(text).find()) { + return message; + } + MutableComponent result = Component.empty(); + // Visit each styled leaf segment preserving its original style. + message.visit((style, segment) -> { + Matcher matcher = EMOTE_PATTERN.matcher(segment); + int last = 0; + while (matcher.find()) { + if (matcher.start() > last) { + result.append(Component.literal(segment.substring(last, matcher.start())).withStyle(style)); + } + String shortcode = matcher.group("emote"); + Integer codepoint = EmoteRegistry.codepointFor(shortcode); + if (codepoint != null) { + String glyph = new String(Character.toChars(codepoint)); + Style emoteStyle = Style.EMPTY.withFont(EmoteRegistry.font()).withColor(ChatFormatting.WHITE).withHoverEvent(new HoverEvent.ShowText(Component.literal(":" + shortcode + ":").withStyle(ChatFormatting.GRAY))); + result.append(Component.literal(glyph).withStyle(emoteStyle)); + } else { + result.append(Component.literal(":" + shortcode + ":").withStyle(style)); + } + last = matcher.end(); + } + if (last < segment.length()) { + result.append(Component.literal(segment.substring(last)).withStyle(style)); + } + return Optional.empty(); + }, Style.EMPTY); + return result; + } + /** Wrap the body to chat width, prefixing line 1 with the shield and the rest with bars. */ private static Component withGuildPrefix(Component body) { Component shield = prefix(SHIELD); diff --git a/src/tel/eden/mod/chat/EmoteRegistry.java b/src/tel/eden/mod/chat/EmoteRegistry.java new file mode 100644 index 0000000..77d7e74 --- /dev/null +++ b/src/tel/eden/mod/chat/EmoteRegistry.java @@ -0,0 +1,97 @@ +package tel.eden.mod.chat; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.network.chat.FontDescription; +import net.minecraft.resources.Identifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Resolves {@code :shortcode:} tokens in relayed Discord messages to the + * private-use codepoint that the {@code edenmod:emotes} bitmap font renders as + * an inline image, e.g. {@code :catplush1:} -> the glyph backed by + * {@code textures/catplush/catplush1.png}. + * + *

Both the font ({@code assets/edenmod/font/emotes.json}) and the manifest + * this class reads ({@code assets/edenmod/emotes_manifest.json}) are generated + * at build time by the {@code generateEmoteAssets} Gradle task from whatever + * PNGs exist under {@code resources/assets/edenmod/textures/}. Adding a new + * emote is purely an asset change — drop a PNG in one of the configured + * directories and rebuild; nothing in this class needs to change. + */ +public final class EmoteRegistry { + private static final Logger LOGGER = LoggerFactory.getLogger("edenmod"); + private static final String MANIFEST_PATH = "/assets/edenmod/emotes_manifest.json"; + private static final String DEFAULT_FONT_ID = "edenmod:emotes"; + + private static volatile Map emotes; + private static volatile FontDescription font; + private static volatile boolean failedToLoad; + + private EmoteRegistry() { + } + + /** The private-use codepoint for {@code shortcode} (without colons), or {@code null} if unknown. */ + public static Integer codepointFor(String shortcode) { + ensureLoaded(); + return emotes.get(shortcode); + } + + /** The font every emote glyph renders with. */ + public static FontDescription font() { + ensureLoaded(); + return font; + } + + private static void ensureLoaded() { + if (emotes != null) { + return; + } + synchronized (EmoteRegistry.class) { + if (emotes == null) { + load(); + } + } + } + + private static void load() { + Map loaded = new ConcurrentHashMap<>(); + String fontId = DEFAULT_FONT_ID; + try (InputStream in = EmoteRegistry.class.getResourceAsStream(MANIFEST_PATH)) { + if (in == null) { + // Not fatal: happens if someone runs from sources without ever having + // built (the manifest is gitignored, generated). Emotes just render + // as their literal ":shortcode:" text until the project is built. + LOGGER.warn("No emote manifest at {} — run a build to generate it; chat emotes will render as plain text until then", MANIFEST_PATH); + } else { + try (InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + JsonObject root = new Gson().fromJson(reader, JsonObject.class); + if (root != null) { + if (root.has("font") && !root.get("font").isJsonNull()) { + fontId = root.get("font").getAsString(); + } + JsonObject entries = root.getAsJsonObject("emotes"); + if (entries != null) { + for (String shortcode : entries.keySet()) { + loaded.put(shortcode, entries.get(shortcode).getAsInt()); + } + } + } + } + } + } catch (IOException | RuntimeException e) { + LOGGER.warn("Failed to load emote manifest; chat emotes disabled", e); + loaded.clear(); + } + font = new FontDescription.Resource(Identifier.parse(fontId)); + emotes = Collections.unmodifiableMap(loaded); + } +} \ No newline at end of file diff --git a/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java b/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java index 89c4bcf..90850f6 100644 --- a/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java +++ b/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java @@ -1,28 +1,37 @@ package tel.eden.mod.mixin; import tel.eden.mod.EdenModClient; +import tel.eden.mod.chat.DiscordChatFormatter; +import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.network.chat.Component; import net.minecraft.network.protocol.game.ClientboundSystemChatPacket; 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; -/** - * Captures system chat at the earliest point — at the HEAD of - * {@code handleSystemChat}, before Wynntils or the Fabric message API can cancel - * or reformat it — and forwards the raw component to the bridge. - */ @Mixin(ClientPacketListener.class) public class ClientPacketListenerMixin { - @Inject(method = "handleSystemChat", at = @At("HEAD")) + @Inject(method = "handleSystemChat", at = @At("HEAD"), cancellable = true) private void edenBridge$captureGuildChat(ClientboundSystemChatPacket packet, CallbackInfo ci) { if (packet.overlay()) { - return; // action-bar/overlay messages are never guild chat + return; } EdenModClient mod = EdenModClient.instance(); if (mod != null) { mod.handleSystemChat(packet.content()); } + Component modified = DiscordChatFormatter.processEmotes(packet.content()); + if (modified != packet.content()) { + Component finalModified = modified; + ci.cancel(); + Minecraft.getInstance().execute(() -> { + Minecraft mc = Minecraft.getInstance(); + if (mc.player != null) { + mc.player.displayClientMessage(finalModified, false); + } + }); + } } }