Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ Thumbs.db

# Backups
*.bak
resources/assets/edenmod/font/emotes.json
resources/assets/edenmod/emotes_manifest.json
67 changes: 67 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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/<namespace>/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}" }
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 79 additions & 8 deletions src/tel/eden/mod/chat/DiscordChatFormatter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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("(?<url>https?://\\S+)|:(?<emote>[a-zA-Z0-9_+\\-]{2,32}):");

// Matches any :shortcode: token (shared with linkify).
private static final Pattern EMOTE_PATTERN = Pattern.compile(":(?<emote>[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).
Expand Down Expand Up @@ -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()) {
Expand All @@ -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);
Expand Down
97 changes: 97 additions & 0 deletions src/tel/eden/mod/chat/EmoteRegistry.java
Original file line number Diff line number Diff line change
@@ -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:} -&gt; the glyph backed by
* {@code textures/catplush/catplush1.png}.
*
* <p>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<String, Integer> 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<String, Integer> 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);
}
}
23 changes: 16 additions & 7 deletions src/tel/eden/mod/mixin/ClientPacketListenerMixin.java
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
}
}