From c25e394d501f42d0db6761b2467e3e20b21ccdaa Mon Sep 17 00:00:00 2001 From: arkonsadter Date: Sun, 21 Jun 2026 21:16:32 +0600 Subject: [PATCH] Add friends, markers, HUD overlays, tab complete - FriendsManager persists a per-user friend list at config/clientirc-friends.json. - MarkerManager holds one ephemeral marker per owner (5 min expiry). - IRC protocol extended with marker_set / marker_remove; server relays them. - IrcService DisplayLine replaced with sealed IrcEvent (Chat / System / MarkerSet / MarkerRemove); chat formatting moved into the client. - ClientIrcMod handles .friends add/remove/list, .marker remove, .help; chat lines from friends are prefixed with a green dot; marker events from non-friends are ignored. - OverlayHud draws a top-center compass strip and a top-left watermark (nick / FPS / ping) via HudLayerRegistrationCallback. - WorldOverlayRenderer draws green crosses above friends and magenta beams + base cubes at active markers via WorldRenderEvents.AFTER_ENTITIES. - MiddleClickHandler binds MMB via KeyMapping, raycasts a block or 20-block look ray, stores locally and sends marker_set on a daemon executor. - ChatScreenMixin completes . prefix commands on Tab; first mixin in the project, plumbed through clientirc.client.mixins.json and fabric.mod.json. --- server/irc-server.js | 40 +++++ .../ru/nevetime/clientirc/ClientIrcMod.java | 149 +++++++++++++++--- .../ru/nevetime/clientirc/MarkerManager.java | 56 +++++++ .../clientirc/MiddleClickHandler.java | 72 +++++++++ .../ru/nevetime/clientirc/OverlayHud.java | 126 +++++++++++++++ .../clientirc/WorldOverlayRenderer.java | 120 ++++++++++++++ .../clientirc/mixin/ChatScreenMixin.java | 49 ++++++ .../resources/clientirc.client.mixins.json | 11 ++ .../ru/nevetime/clientirc/FriendsManager.java | 129 +++++++++++++++ .../ru/nevetime/clientirc/IrcMessage.java | 60 +++++-- .../ru/nevetime/clientirc/IrcService.java | 115 ++++++++++---- src/main/resources/fabric.mod.json | 3 + 12 files changed, 864 insertions(+), 66 deletions(-) create mode 100644 src/client/java/ru/nevetime/clientirc/MarkerManager.java create mode 100644 src/client/java/ru/nevetime/clientirc/MiddleClickHandler.java create mode 100644 src/client/java/ru/nevetime/clientirc/OverlayHud.java create mode 100644 src/client/java/ru/nevetime/clientirc/WorldOverlayRenderer.java create mode 100644 src/client/java/ru/nevetime/clientirc/mixin/ChatScreenMixin.java create mode 100644 src/client/resources/clientirc.client.mixins.json create mode 100644 src/main/java/ru/nevetime/clientirc/FriendsManager.java diff --git a/server/irc-server.js b/server/irc-server.js index b4c2146..8c17fdd 100644 --- a/server/irc-server.js +++ b/server/irc-server.js @@ -106,6 +106,46 @@ const server = net.createServer((socket) => { continue; } + if (payload.type === 'marker_set') { + const clientName = String(payload.client || 'UNKNOWN-CLIENT').trim(); + const playerName = String(payload.player || 'unknown').trim(); + const dimensionId = String(payload.dimensionId || 'minecraft:overworld').trim(); + const x = Number(payload.x); + const y = Number(payload.y); + const z = Number(payload.z); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { + sendJson(socket, systemMessage('Bad marker_set coords.')); + continue; + } + const outgoing = { + type: 'marker_set', + client: clientName, + player: playerName, + dimensionId, + x, + y, + z, + timestamp: Date.now() + }; + console.log(`[marker_set] ${clientName} >> ${playerName} @ ${dimensionId} ${x.toFixed(1)},${y.toFixed(1)},${z.toFixed(1)}`); + broadcast(outgoing); + continue; + } + + if (payload.type === 'marker_remove') { + const clientName = String(payload.client || 'UNKNOWN-CLIENT').trim(); + const playerName = String(payload.player || 'unknown').trim(); + const outgoing = { + type: 'marker_remove', + client: clientName, + player: playerName, + timestamp: Date.now() + }; + console.log(`[marker_remove] ${clientName} >> ${playerName}`); + broadcast(outgoing); + continue; + } + sendJson(socket, systemMessage(`Unknown packet type: ${payload.type}`)); } }); diff --git a/src/client/java/ru/nevetime/clientirc/ClientIrcMod.java b/src/client/java/ru/nevetime/clientirc/ClientIrcMod.java index d13d297..80df431 100644 --- a/src/client/java/ru/nevetime/clientirc/ClientIrcMod.java +++ b/src/client/java/ru/nevetime/clientirc/ClientIrcMod.java @@ -1,5 +1,10 @@ package ru.nevetime.clientirc; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; @@ -8,17 +13,15 @@ import net.fabricmc.loader.api.FabricLoader; import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - public final class ClientIrcMod implements ClientModInitializer { public static final String MOD_ID = "clientirc"; public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); - private static final String COMMAND_PREFIX = ".irc"; + private static final String COMMAND_PREFIX = "."; private static final ExecutorService IO_EXECUTOR = Executors.newSingleThreadExecutor(runnable -> { Thread thread = new Thread(runnable, "clientirc-io"); @@ -31,9 +34,15 @@ public final class ClientIrcMod implements ClientModInitializer { @Override public void onInitializeClient() { - config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json")); + Path configDir = FabricLoader.getInstance().getConfigDir(); + config = IrcConfig.load(configDir.resolve("clientirc.json")); + FriendsManager.init(configDir); service = new IrcService(config, LOGGER); + OverlayHud.init(); + WorldOverlayRenderer.init(); + MiddleClickHandler.init(() -> service, () -> resolvePlayerName(Minecraft.getInstance())); + ClientLifecycleEvents.CLIENT_STARTED.register(client -> { if (config.autoConnect) { String playerName = resolvePlayerName(client); @@ -47,16 +56,44 @@ public final class ClientIrcMod implements ClientModInitializer { }); ClientTickEvents.END_CLIENT_TICK.register(client -> { - IrcService.DisplayLine line; - while ((line = service.pollLine()) != null) { - ChatFormatting color = line.system() ? ChatFormatting.GRAY : ChatFormatting.AQUA; - pushClientChat(client, Component.literal(line.text()).withStyle(color)); + IrcService.IrcEvent event; + while ((event = service.pollEvent()) != null) { + handleEvent(client, event); } }); ClientSendMessageEvents.ALLOW_CHAT.register(message -> !interceptOutgoingChat(message)); } + private static void handleEvent(Minecraft client, IrcService.IrcEvent event) { + switch (event) { + case IrcService.IrcEvent.Chat chat -> { + MutableComponent line = Component.literal("[IRC] " + chat.client() + " >> ").withStyle(ChatFormatting.AQUA); + if (FriendsManager.isFriend(chat.sender())) { + line.append(Component.literal("● ").withStyle(ChatFormatting.GREEN)); + } + line.append(Component.literal(chat.sender() + ": " + chat.message()).withStyle(ChatFormatting.AQUA)); + pushClientChat(client, line); + } + case IrcService.IrcEvent.System sys -> + pushClientChat(client, Component.literal("[IRC] " + sys.message()).withStyle(ChatFormatting.GRAY)); + case IrcService.IrcEvent.MarkerSet ms -> { + if (FriendsManager.isFriend(ms.sender())) { + MarkerManager.set(new MarkerManager.Marker( + ms.sender(), ms.dimensionId(), ms.x(), ms.y(), ms.z(), System.currentTimeMillis() + )); + pushClientChat(client, Component.literal("[IRC] marker from " + ms.sender()).withStyle(ChatFormatting.LIGHT_PURPLE)); + } + } + case IrcService.IrcEvent.MarkerRemove mr -> { + if (FriendsManager.isFriend(mr.sender())) { + MarkerManager.remove(mr.sender()); + pushClientChat(client, Component.literal("[IRC] marker removed by " + mr.sender()).withStyle(ChatFormatting.LIGHT_PURPLE)); + } + } + } + } + private static boolean interceptOutgoingChat(String rawMessage) { String trimmed = rawMessage.trim(); if (!trimmed.startsWith(COMMAND_PREFIX)) { @@ -64,20 +101,34 @@ public final class ClientIrcMod implements ClientModInitializer { } Minecraft client = Minecraft.getInstance(); - String argument = trimmed.length() == COMMAND_PREFIX.length() ? "" : trimmed.substring(COMMAND_PREFIX.length()).trim(); + String[] parts = trimmed.split("\\s+", 2); + String cmd = parts[0].toLowerCase(Locale.ROOT); + String rest = parts.length > 1 ? parts[1].trim() : ""; - if (argument.isBlank()) { + return switch (cmd) { + case ".irc" -> handleIrcCommand(client, rest); + case ".friends" -> handleFriendsCommand(client, rest); + case ".marker" -> handleMarkerCommand(client, rest); + case ".help" -> { + showHelp(client); + yield true; + } + default -> false; + }; + } + + private static boolean handleIrcCommand(Minecraft client, String rest) { + if (rest.isBlank()) { pushClientChat(client, Component.literal("[IRC] Usage: .irc | .irc status | .irc reconnect | .irc reload").withStyle(ChatFormatting.YELLOW)); return true; } - - if ("status".equalsIgnoreCase(argument)) { + String lower = rest.toLowerCase(Locale.ROOT); + if ("status".equals(lower)) { String status = service.isConnected() ? "connected" : "disconnected"; pushClientChat(client, Component.literal("[IRC] " + status + " | " + config.host + ":" + config.port + " | clientName=" + config.clientName).withStyle(ChatFormatting.YELLOW)); return true; } - - if ("reconnect".equalsIgnoreCase(argument)) { + if ("reconnect".equals(lower)) { String playerName = resolvePlayerName(client); IO_EXECUTOR.execute(() -> { boolean connected = service.reconnect(playerName); @@ -86,27 +137,87 @@ public final class ClientIrcMod implements ClientModInitializer { }); return true; } - - if ("reload".equalsIgnoreCase(argument)) { + if ("reload".equals(lower)) { config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json")); service.updateConfig(config); pushClientChat(client, Component.literal("[IRC] config reloaded").withStyle(ChatFormatting.YELLOW)); return true; } - String playerName = resolvePlayerName(client); IO_EXECUTOR.execute(() -> { if (!service.ensureConnected(playerName)) { pushClientChat(client, Component.literal("[IRC] server is offline or config is wrong").withStyle(ChatFormatting.RED)); return; } - if (!service.sendChat(playerName, argument)) { + if (!service.sendChat(playerName, rest)) { pushClientChat(client, Component.literal("[IRC] send failed").withStyle(ChatFormatting.RED)); } }); return true; } + private static boolean handleFriendsCommand(Minecraft client, String rest) { + if (rest.isBlank()) { + pushClientChat(client, Component.literal("[IRC] Usage: .friends add | .friends remove | .friends list").withStyle(ChatFormatting.YELLOW)); + return true; + } + String[] parts = rest.split("\\s+", 2); + String sub = parts[0].toLowerCase(Locale.ROOT); + String arg = parts.length > 1 ? parts[1].trim() : ""; + + if ("add".equals(sub)) { + if (arg.isBlank()) { + pushClientChat(client, Component.literal("[IRC] usage: .friends add ").withStyle(ChatFormatting.YELLOW)); + return true; + } + boolean added = FriendsManager.add(arg); + pushClientChat(client, Component.literal("[IRC] " + (added ? "added friend " + arg : arg + " is already a friend")) + .withStyle(added ? ChatFormatting.GREEN : ChatFormatting.YELLOW)); + return true; + } + if ("remove".equals(sub)) { + if (arg.isBlank()) { + pushClientChat(client, Component.literal("[IRC] usage: .friends remove ").withStyle(ChatFormatting.YELLOW)); + return true; + } + boolean removed = FriendsManager.remove(arg); + pushClientChat(client, Component.literal("[IRC] " + (removed ? "removed friend " + arg : arg + " was not a friend")) + .withStyle(removed ? ChatFormatting.GREEN : ChatFormatting.YELLOW)); + return true; + } + if ("list".equals(sub)) { + List friends = FriendsManager.list(); + if (friends.isEmpty()) { + pushClientChat(client, Component.literal("[IRC] friends list is empty").withStyle(ChatFormatting.YELLOW)); + } else { + pushClientChat(client, Component.literal("[IRC] friends (" + friends.size() + "): " + String.join(", ", friends)) + .withStyle(ChatFormatting.GREEN)); + } + return true; + } + pushClientChat(client, Component.literal("[IRC] unknown subcommand. Use add/remove/list.").withStyle(ChatFormatting.YELLOW)); + return true; + } + + private static boolean handleMarkerCommand(Minecraft client, String rest) { + String lower = rest.toLowerCase(Locale.ROOT); + if ("remove".equals(lower)) { + MiddleClickHandler.removeOwnMarker(() -> service, () -> resolvePlayerName(client)); + pushClientChat(client, Component.literal("[IRC] your marker removed").withStyle(ChatFormatting.YELLOW)); + return true; + } + pushClientChat(client, Component.literal("[IRC] Usage: middle-click to drop a marker (friends only see it) | .marker remove").withStyle(ChatFormatting.YELLOW)); + return true; + } + + private static void showHelp(Minecraft client) { + pushClientChat(client, Component.literal("[IRC] Commands:").withStyle(ChatFormatting.YELLOW)); + pushClientChat(client, Component.literal(" .irc | .irc status | .irc reconnect | .irc reload").withStyle(ChatFormatting.GRAY)); + pushClientChat(client, Component.literal(" .friends add | .friends remove | .friends list").withStyle(ChatFormatting.GRAY)); + pushClientChat(client, Component.literal(" MMB drops a marker (friends only see it) | .marker remove").withStyle(ChatFormatting.GRAY)); + pushClientChat(client, Component.literal(" .help shows this list").withStyle(ChatFormatting.GRAY)); + } + private static void pushClientChat(Minecraft client, Component text) { if (client == null) { return; diff --git a/src/client/java/ru/nevetime/clientirc/MarkerManager.java b/src/client/java/ru/nevetime/clientirc/MarkerManager.java new file mode 100644 index 0000000..82bd941 --- /dev/null +++ b/src/client/java/ru/nevetime/clientirc/MarkerManager.java @@ -0,0 +1,56 @@ +package ru.nevetime.clientirc; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class MarkerManager { + private static final long EXPIRY_MS = 5L * 60L * 1000L; + private static final ConcurrentHashMap MARKERS = new ConcurrentHashMap<>(); + + private MarkerManager() { + } + + public record Marker(String owner, String dimensionId, double x, double y, double z, long createdAtMs) { + } + + public static void set(Marker marker) { + if (marker == null || marker.owner() == null || marker.owner().isBlank()) { + return; + } + MARKERS.put(marker.owner().toLowerCase(Locale.ROOT), marker); + } + + public static void remove(String owner) { + if (owner == null || owner.isBlank()) { + return; + } + MARKERS.remove(owner.toLowerCase(Locale.ROOT)); + } + + public static void clear() { + MARKERS.clear(); + } + + public static Collection active() { + long now = System.currentTimeMillis(); + ArrayList alive = new ArrayList<>(MARKERS.size()); + Iterator> it = MARKERS.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + if (now - entry.getValue().createdAtMs() > EXPIRY_MS) { + it.remove(); + } else { + alive.add(entry.getValue()); + } + } + return alive; + } + + public static long expiryMs() { + return EXPIRY_MS; + } +} diff --git a/src/client/java/ru/nevetime/clientirc/MiddleClickHandler.java b/src/client/java/ru/nevetime/clientirc/MiddleClickHandler.java new file mode 100644 index 0000000..bc390e4 --- /dev/null +++ b/src/client/java/ru/nevetime/clientirc/MiddleClickHandler.java @@ -0,0 +1,72 @@ +package ru.nevetime.clientirc; + +import com.mojang.blaze3d.platform.InputConstants; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Supplier; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.lwjgl.glfw.GLFW; + +public final class MiddleClickHandler { + private static final ExecutorService IO_EXECUTOR = + Executors.newSingleThreadExecutor(runnable -> { + Thread t = new Thread(runnable, "clientirc-marker-io"); + t.setDaemon(true); + return t; + }); + + private static KeyMapping markerKey; + + private MiddleClickHandler() {} + + public static void init(Supplier serviceSupplier, Supplier playerNameSupplier) { + markerKey = new KeyMapping( + "key.clientirc.marker", + InputConstants.Type.MOUSE, + GLFW.GLFW_MOUSE_BUTTON_MIDDLE, + "category.clientirc" + ); + KeyBindingHelper.registerKeyBinding(markerKey); + + ClientTickEvents.END_CLIENT_TICK.register(client -> { + while (markerKey.consumeClick()) { + if (client.player == null || client.level == null) break; + + Vec3 target; + if (client.hitResult instanceof BlockHitResult br && br.getType() != HitResult.Type.MISS) { + target = br.getBlockPos().getCenter(); + } else { + Vec3 eye = client.player.getEyePosition(); + Vec3 look = client.player.getViewVector(1.0f); + target = eye.add(look.scale(20.0)); + } + + String dimId = client.level.dimension().location().toString(); + String playerName = playerNameSupplier.get(); + double tx = target.x, ty = target.y, tz = target.z; + + MarkerManager.set(new MarkerManager.Marker(playerName, dimId, tx, ty, tz, System.currentTimeMillis())); + + IO_EXECUTOR.execute(() -> { + IrcService service = serviceSupplier.get(); + if (service != null) service.sendMarkerSet(playerName, dimId, tx, ty, tz); + }); + } + }); + } + + public static void removeOwnMarker(Supplier serviceSupplier, Supplier playerNameSupplier) { + String name = playerNameSupplier.get(); + MarkerManager.remove(name); + IO_EXECUTOR.execute(() -> { + IrcService service = serviceSupplier.get(); + if (service != null) service.sendMarkerRemove(name); + }); + } +} diff --git a/src/client/java/ru/nevetime/clientirc/OverlayHud.java b/src/client/java/ru/nevetime/clientirc/OverlayHud.java new file mode 100644 index 0000000..6065a1b --- /dev/null +++ b/src/client/java/ru/nevetime/clientirc/OverlayHud.java @@ -0,0 +1,126 @@ +package ru.nevetime.clientirc; + +import com.mojang.authlib.GameProfile; +import net.fabricmc.fabric.api.client.rendering.v1.HudLayerRegistrationCallback; +import net.fabricmc.fabric.api.client.rendering.v1.IdentifiedLayer; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.LayeredDraw; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.resources.ResourceLocation; + +public final class OverlayHud { + private static final ResourceLocation COMPASS_LAYER = ResourceLocation.fromNamespaceAndPath("clientirc", "compass"); + private static final ResourceLocation WATERMARK_LAYER = ResourceLocation.fromNamespaceAndPath("clientirc", "watermark"); + + private static final int COMPASS_WIDTH = 180; + private static final int COMPASS_HEIGHT = 14; + private static final int COMPASS_Y = 2; + + private OverlayHud() { + } + + public static void init() { + HudLayerRegistrationCallback.EVENT.register(layered -> { + layered.attachLayerAfter(IdentifiedLayer.MISC_OVERLAYS, COMPASS_LAYER, (LayeredDraw.Layer) OverlayHud::renderCompass); + layered.attachLayerAfter(IdentifiedLayer.MISC_OVERLAYS, WATERMARK_LAYER, (LayeredDraw.Layer) OverlayHud::renderWatermark); + }); + } + + private static boolean skip(Minecraft mc) { + if (mc == null) { + return true; + } + if (mc.options != null && mc.options.hideGui) { + return true; + } + return mc.level == null; + } + + private static void renderCompass(GuiGraphics graphics, DeltaTracker deltaTracker) { + Minecraft mc = Minecraft.getInstance(); + if (skip(mc)) { + return; + } + Font font = mc.font; + + int x = (graphics.guiWidth() - COMPASS_WIDTH) / 2; + int y = COMPASS_Y; + + graphics.fill(x, y, x + COMPASS_WIDTH, y + COMPASS_HEIGHT, 0x80000000); + + float yaw = mc.gameRenderer.getMainCamera().getYRot(); + + int[] cardYaws = {180, -90, 0, 90}; + String[] cardLabels = {"N", "E", "S", "W"}; + + for (int i = 0; i < cardYaws.length; i++) { + float delta = wrapDegrees(cardYaws[i] - yaw); + if (Math.abs(delta) > 90.0f) { + continue; + } + int px = x + 90 + (int) delta; + int color = Math.abs(delta) <= 5.0f ? 0xFFFFFF55 : 0xFFFFFFFF; + int labelWidth = font.width(cardLabels[i]); + graphics.drawString(font, cardLabels[i], px - labelWidth / 2, y + 3, color, true); + } + + graphics.fill(x + 89, y + 11, x + 92, y + 14, 0xFFFFFFFF); + } + + private static void renderWatermark(GuiGraphics graphics, DeltaTracker deltaTracker) { + Minecraft mc = Minecraft.getInstance(); + if (skip(mc)) { + return; + } + Font font = mc.font; + int x = 4; + int y = 4; + int lineHeight = font.lineHeight + 2; + + String nick = resolveNick(mc); + int fps = mc.getFps(); + int ping = resolvePing(mc); + + graphics.drawString(font, nick, x, y, 0xFFFFFFFF, true); + graphics.drawString(font, "FPS: " + fps, x, y + lineHeight, 0xFFFFFFFF, true); + graphics.drawString(font, "Ping: " + ping + " ms", x, y + 2 * lineHeight, 0xFFFFFFFF, true); + } + + private static String resolveNick(Minecraft mc) { + if (mc.player != null) { + GameProfile profile = mc.player.getGameProfile(); + if (profile != null && profile.name() != null && !profile.name().isBlank()) { + return profile.name(); + } + } + if (mc.getUser() != null) { + String name = mc.getUser().getName(); + if (name != null && !name.isBlank()) { + return name; + } + } + return "—"; + } + + private static int resolvePing(Minecraft mc) { + if (mc.getConnection() == null || mc.getUser() == null) { + return 0; + } + PlayerInfo info = mc.getConnection().getPlayerInfo(mc.getUser().getProfileId()); + return info == null ? 0 : info.getLatency(); + } + + private static float wrapDegrees(float v) { + v = v % 360.0f; + if (v >= 180.0f) { + v -= 360.0f; + } + if (v < -180.0f) { + v += 360.0f; + } + return v; + } +} diff --git a/src/client/java/ru/nevetime/clientirc/WorldOverlayRenderer.java b/src/client/java/ru/nevetime/clientirc/WorldOverlayRenderer.java new file mode 100644 index 0000000..90dd830 --- /dev/null +++ b/src/client/java/ru/nevetime/clientirc/WorldOverlayRenderer.java @@ -0,0 +1,120 @@ +package ru.nevetime.clientirc; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix3f; +import org.joml.Matrix4f; +import java.util.Collection; + +public final class WorldOverlayRenderer { + private WorldOverlayRenderer() {} + + public static void init() { + WorldRenderEvents.AFTER_ENTITIES.register(WorldOverlayRenderer::onAfterEntities); + } + + private static void onAfterEntities(WorldRenderContext context) { + Minecraft client = Minecraft.getInstance(); + if (client.level == null) return; + if (client.options != null && client.options.hideGui) return; + + PoseStack pose = context.matrixStack(); + if (pose == null) return; + + Camera camera = context.camera(); + if (camera == null) return; + Vec3 camPos = camera.getPosition(); + + MultiBufferSource consumers = context.consumers(); + if (consumers == null) return; + + VertexConsumer lines = consumers.getBuffer(RenderType.lines()); + + // Friend dots + for (Player player : client.level.players()) { + if (player == client.player) continue; + if (player.getGameProfile() == null) continue; + if (FriendsManager.isFriend(player.getGameProfile().name())) { + double px = player.getX() - camPos.x; + double py = player.getY() + player.getBbHeight() + 0.5 - camPos.y; + double pz = player.getZ() - camPos.z; + int r = 0x33, g = 0xFF, b = 0x55, a = 0xFF; + drawLine(lines, pose, px - 0.15, py, pz, px + 0.15, py, pz, r, g, b, a); + drawLine(lines, pose, px, py - 0.15, pz, px, py + 0.15, pz, r, g, b, a); + drawLine(lines, pose, px, py, pz - 0.15, px, py, pz + 0.15, r, g, b, a); + } + } + + // Marker beacons + String currentDim = client.level.dimension().location().toString(); + Collection markers = MarkerManager.active(); + for (MarkerManager.Marker marker : markers) { + if (!currentDim.equals(marker.dimensionId())) continue; + double mx = marker.x() - camPos.x; + double my = marker.y() - camPos.y; + double mz = marker.z() - camPos.z; + int r = 0xFF, g = 0x55, b = 0xFF, a = 0xFF; + + // Vertical beam + drawLine(lines, pose, mx, my, mz, mx, my + 50.0, mz, r, g, b, a); + + // 1x1x1 cube outline centered at (mx, my + 0.5, mz) + double cx = mx, cy = my + 0.5, cz = mz; + double x0 = cx - 0.5, x1 = cx + 0.5; + double y0 = cy - 0.5, y1 = cy + 0.5; + double z0 = cz - 0.5, z1 = cz + 0.5; + + // Bottom square (y = y0) + drawLine(lines, pose, x0, y0, z0, x1, y0, z0, r, g, b, a); + drawLine(lines, pose, x1, y0, z0, x1, y0, z1, r, g, b, a); + drawLine(lines, pose, x1, y0, z1, x0, y0, z1, r, g, b, a); + drawLine(lines, pose, x0, y0, z1, x0, y0, z0, r, g, b, a); + + // Top square (y = y1) + drawLine(lines, pose, x0, y1, z0, x1, y1, z0, r, g, b, a); + drawLine(lines, pose, x1, y1, z0, x1, y1, z1, r, g, b, a); + drawLine(lines, pose, x1, y1, z1, x0, y1, z1, r, g, b, a); + drawLine(lines, pose, x0, y1, z1, x0, y1, z0, r, g, b, a); + + // Vertical edges + drawLine(lines, pose, x0, y0, z0, x0, y1, z0, r, g, b, a); + drawLine(lines, pose, x1, y0, z0, x1, y1, z0, r, g, b, a); + drawLine(lines, pose, x1, y0, z1, x1, y1, z1, r, g, b, a); + drawLine(lines, pose, x0, y0, z1, x0, y1, z1, r, g, b, a); + } + } + + private static void drawLine(VertexConsumer lines, PoseStack pose, + double x1, double y1, double z1, + double x2, double y2, double z2, + int r, int g, int b, int a) { + Matrix4f matrix = pose.last().pose(); + Matrix3f normalMatrix = pose.last().normal(); + + double dx = x2 - x1; + double dy = y2 - y1; + double dz = z2 - z1; + double len = Math.sqrt(dx * dx + dy * dy + dz * dz); + float nx, ny, nz; + if (len < 1e-6) { + nx = 0.0f; + ny = 1.0f; + nz = 0.0f; + } else { + nx = (float) (dx / len); + ny = (float) (dy / len); + nz = (float) (dz / len); + } + + lines.vertex(matrix, (float) x1, (float) y1, (float) z1).color(r, g, b, a).normal(normalMatrix, nx, ny, nz); + lines.vertex(matrix, (float) x2, (float) y2, (float) z2).color(r, g, b, a).normal(normalMatrix, nx, ny, nz); + } +} diff --git a/src/client/java/ru/nevetime/clientirc/mixin/ChatScreenMixin.java b/src/client/java/ru/nevetime/clientirc/mixin/ChatScreenMixin.java new file mode 100644 index 0000000..256a03e --- /dev/null +++ b/src/client/java/ru/nevetime/clientirc/mixin/ChatScreenMixin.java @@ -0,0 +1,49 @@ +package ru.nevetime.clientirc.mixin; + +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.ChatScreen; +import org.lwjgl.glfw.GLFW; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(ChatScreen.class) +public abstract class ChatScreenMixin { + @Shadow protected EditBox input; + + private static final String[] CLIENTIRC_COMMANDS = new String[] { + ".irc", + ".irc status", + ".irc reconnect", + ".irc reload", + ".friends add ", + ".friends remove ", + ".friends list", + ".marker remove", + ".help" + }; + + @Inject(method = "keyPressed", at = @At("HEAD"), cancellable = true) + private void clientirc$tabComplete(int keyCode, int scanCode, int modifiers, CallbackInfoReturnable cir) { + if (keyCode != GLFW.GLFW_KEY_TAB) return; + if (input == null) return; + String value = input.getValue(); + if (value == null || !value.startsWith(".")) return; + + String best = null; + for (String candidate : CLIENTIRC_COMMANDS) { + if (candidate.startsWith(value) && !candidate.equals(value)) { + if (best == null || candidate.length() < best.length()) { + best = candidate; + } + } + } + if (best != null) { + input.setValue(best); + input.setCursorPosition(best.length()); + cir.setReturnValue(true); + } + } +} diff --git a/src/client/resources/clientirc.client.mixins.json b/src/client/resources/clientirc.client.mixins.json new file mode 100644 index 0000000..6fec7ad --- /dev/null +++ b/src/client/resources/clientirc.client.mixins.json @@ -0,0 +1,11 @@ +{ + "required": true, + "package": "ru.nevetime.clientirc.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "ChatScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/java/ru/nevetime/clientirc/FriendsManager.java b/src/main/java/ru/nevetime/clientirc/FriendsManager.java new file mode 100644 index 0000000..8e11b3e --- /dev/null +++ b/src/main/java/ru/nevetime/clientirc/FriendsManager.java @@ -0,0 +1,129 @@ +package ru.nevetime.clientirc; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.google.gson.annotations.SerializedName; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class FriendsManager { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Logger LOGGER = LoggerFactory.getLogger(FriendsManager.class); + + private static final ConcurrentHashMap FRIENDS = new ConcurrentHashMap<>(); + private static volatile Path file; + + private FriendsManager() { + } + + public static void init(Path configDir) { + file = configDir.resolve("clientirc-friends.json"); + try { + Files.createDirectories(file.getParent()); + } catch (IOException ex) { + LOGGER.warn("Failed to create config directory for friends", ex); + } + load(); + } + + public static boolean add(String name) { + if (name == null || name.isBlank() || file == null) { + return false; + } + String trimmed = name.trim(); + String key = trimmed.toLowerCase(Locale.ROOT); + if (FRIENDS.putIfAbsent(key, trimmed) != null) { + return false; + } + save(); + return true; + } + + public static boolean remove(String name) { + if (name == null || name.isBlank() || file == null) { + return false; + } + String key = name.trim().toLowerCase(Locale.ROOT); + if (FRIENDS.remove(key) == null) { + return false; + } + save(); + return true; + } + + public static boolean isFriend(String name) { + if (name == null || name.isBlank()) { + return false; + } + return FRIENDS.containsKey(name.trim().toLowerCase(Locale.ROOT)); + } + + public static List list() { + List all = new ArrayList<>(FRIENDS.values()); + all.sort(Comparator.comparing(s -> s.toLowerCase(Locale.ROOT))); + return all; + } + + private static void load() { + if (file == null) { + return; + } + if (!Files.exists(file)) { + save(); + return; + } + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + Storage storage = GSON.fromJson(reader, Storage.class); + FRIENDS.clear(); + if (storage != null && storage.friends != null) { + for (String name : storage.friends) { + if (name == null) { + continue; + } + String trimmed = name.trim(); + if (trimmed.isEmpty()) { + continue; + } + FRIENDS.put(trimmed.toLowerCase(Locale.ROOT), trimmed); + } + } + } catch (IOException | JsonParseException ex) { + LOGGER.warn("Failed to load friends list from {}", file, ex); + FRIENDS.clear(); + save(); + } + } + + private static void save() { + if (file == null) { + return; + } + Storage storage = new Storage(); + storage.friends = list(); + try { + Files.createDirectories(file.getParent()); + try (Writer writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) { + GSON.toJson(storage, writer); + } + } catch (IOException ex) { + LOGGER.warn("Failed to save friends list to {}", file, ex); + } + } + + private static final class Storage { + @SerializedName("friends") + List friends; + } +} diff --git a/src/main/java/ru/nevetime/clientirc/IrcMessage.java b/src/main/java/ru/nevetime/clientirc/IrcMessage.java index 14f39a6..376b53e 100644 --- a/src/main/java/ru/nevetime/clientirc/IrcMessage.java +++ b/src/main/java/ru/nevetime/clientirc/IrcMessage.java @@ -6,27 +6,55 @@ public final class IrcMessage { public String player; public String message; public String token; + public String dimensionId; + public Double x; + public Double y; + public Double z; public long timestamp; public static IrcMessage hello(String clientName, String playerName, String token) { - IrcMessage message = new IrcMessage(); - message.type = "hello"; - message.client = clientName; - message.player = playerName; - message.token = token; - message.timestamp = System.currentTimeMillis(); - return message; + IrcMessage m = new IrcMessage(); + m.type = "hello"; + m.client = clientName; + m.player = playerName; + m.token = token; + m.timestamp = System.currentTimeMillis(); + return m; } public static IrcMessage chat(String clientName, String playerName, String text, String token) { - IrcMessage message = new IrcMessage(); - message.type = "chat"; - message.client = clientName; - message.player = playerName; - message.message = text; - message.token = token; - message.timestamp = System.currentTimeMillis(); - return message; + IrcMessage m = new IrcMessage(); + m.type = "chat"; + m.client = clientName; + m.player = playerName; + m.message = text; + m.token = token; + m.timestamp = System.currentTimeMillis(); + return m; + } + + public static IrcMessage markerSet(String clientName, String playerName, String token, + String dimensionId, double x, double y, double z) { + IrcMessage m = new IrcMessage(); + m.type = "marker_set"; + m.client = clientName; + m.player = playerName; + m.token = token; + m.dimensionId = dimensionId; + m.x = x; + m.y = y; + m.z = z; + m.timestamp = System.currentTimeMillis(); + return m; + } + + public static IrcMessage markerRemove(String clientName, String playerName, String token) { + IrcMessage m = new IrcMessage(); + m.type = "marker_remove"; + m.client = clientName; + m.player = playerName; + m.token = token; + m.timestamp = System.currentTimeMillis(); + return m; } } - diff --git a/src/main/java/ru/nevetime/clientirc/IrcService.java b/src/main/java/ru/nevetime/clientirc/IrcService.java index 9bff48c..2c868f0 100644 --- a/src/main/java/ru/nevetime/clientirc/IrcService.java +++ b/src/main/java/ru/nevetime/clientirc/IrcService.java @@ -19,7 +19,7 @@ public final class IrcService { private static final Gson GSON = new Gson(); private final Logger logger; - private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); + private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); private volatile IrcConfig config; private volatile Socket socket; @@ -33,6 +33,20 @@ public final class IrcService { this.logger = Objects.requireNonNull(logger, "logger"); } + public sealed interface IrcEvent { + record Chat(String client, String sender, String message) implements IrcEvent { + } + + record System(String message) implements IrcEvent { + } + + record MarkerSet(String sender, String dimensionId, double x, double y, double z) implements IrcEvent { + } + + record MarkerRemove(String sender) implements IrcEvent { + } + } + public synchronized void updateConfig(IrcConfig config) { this.config = Objects.requireNonNull(config, "config"); } @@ -66,12 +80,12 @@ public final class IrcService { sendInternal(IrcMessage.hello(config.clientName, playerName, config.token)); enqueueSystem("Connected to " + config.host + ":" + config.port); return true; - } catch (IOException exception) { + } catch (IOException ex) { closeQuietly(newReader); closeQuietly(newWriter); closeQuietly(newSocket); - enqueueSystem("Connection failed: " + exception.getMessage()); - logger.warn("IRC connection failed", exception); + enqueueSystem("Connection failed: " + ex.getMessage()); + logger.warn("IRC connection failed", ex); return false; } } @@ -90,13 +104,44 @@ public final class IrcService { if (!isConnected()) { return false; } - try { sendInternal(IrcMessage.chat(config.clientName, playerName, text, config.token)); return true; - } catch (IOException exception) { - enqueueSystem("Send failed: " + exception.getMessage()); - logger.warn("Failed to send IRC message", exception); + } catch (IOException ex) { + enqueueSystem("Send failed: " + ex.getMessage()); + logger.warn("Failed to send IRC chat", ex); + running = false; + closeSocketState(); + return false; + } + } + + public synchronized boolean sendMarkerSet(String playerName, String dimensionId, double x, double y, double z) { + if (!isConnected()) { + return false; + } + try { + sendInternal(IrcMessage.markerSet(config.clientName, playerName, config.token, dimensionId, x, y, z)); + return true; + } catch (IOException ex) { + enqueueSystem("Send failed: " + ex.getMessage()); + logger.warn("Failed to send IRC marker_set", ex); + running = false; + closeSocketState(); + return false; + } + } + + public synchronized boolean sendMarkerRemove(String playerName) { + if (!isConnected()) { + return false; + } + try { + sendInternal(IrcMessage.markerRemove(config.clientName, playerName, config.token)); + return true; + } catch (IOException ex) { + enqueueSystem("Send failed: " + ex.getMessage()); + logger.warn("Failed to send IRC marker_remove", ex); running = false; closeSocketState(); return false; @@ -107,7 +152,7 @@ public final class IrcService { return socket != null && socket.isConnected() && !socket.isClosed() && writer != null; } - public DisplayLine pollLine() { + public IrcEvent pollEvent() { return queue.poll(); } @@ -124,14 +169,13 @@ public final class IrcService { while (running && (line = activeReader.readLine()) != null) { handleIncomingLine(line); } - if (running) { enqueueSystem("Server closed the connection."); } - } catch (IOException exception) { + } catch (IOException ex) { if (running) { - enqueueSystem("Disconnected: " + exception.getMessage()); - logger.warn("IRC reader stopped", exception); + enqueueSystem("Disconnected: " + ex.getMessage()); + logger.warn("IRC reader stopped", ex); } } finally { running = false; @@ -148,25 +192,39 @@ public final class IrcService { return; } - if ("chat".equals(message.type)) { - String clientName = safeValue(message.client, "UNKNOWN-CLIENT"); - String playerName = safeValue(message.player, "unknown"); - String text = safeValue(message.message, ""); - queue.add(new DisplayLine("[IRC] " + clientName + " >> " + playerName + ": " + text, false)); - return; + switch (message.type) { + case "chat" -> { + String clientName = safeValue(message.client, "UNKNOWN-CLIENT"); + String sender = safeValue(message.player, "unknown"); + String text = safeValue(message.message, ""); + queue.add(new IrcEvent.Chat(clientName, sender, text)); + } + case "system" -> { + if (message.message != null && !message.message.isBlank()) { + queue.add(new IrcEvent.System(message.message)); + } + } + case "marker_set" -> { + if (message.x != null && message.y != null && message.z != null && message.dimensionId != null) { + String sender = safeValue(message.player, "unknown"); + queue.add(new IrcEvent.MarkerSet(sender, message.dimensionId, message.x, message.y, message.z)); + } + } + case "marker_remove" -> { + String sender = safeValue(message.player, "unknown"); + queue.add(new IrcEvent.MarkerRemove(sender)); + } + default -> { + } } - - if ("system".equals(message.type) && message.message != null && !message.message.isBlank()) { - enqueueSystem(message.message); - } - } catch (JsonParseException exception) { + } catch (JsonParseException ex) { enqueueSystem("Malformed packet: " + rawLine); - logger.warn("Failed to parse IRC packet: {}", rawLine, exception); + logger.warn("Failed to parse IRC packet: {}", rawLine, ex); } } private void enqueueSystem(String text) { - queue.add(new DisplayLine("[IRC] " + text, true)); + queue.add(new IrcEvent.System(text)); } private void sendInternal(IrcMessage message) throws IOException { @@ -189,7 +247,6 @@ public final class IrcService { if (closeable == null) { return; } - try { closeable.close(); } catch (IOException ignored) { @@ -199,8 +256,4 @@ public final class IrcService { private static String safeValue(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; } - - public record DisplayLine(String text, boolean system) { - } } - diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 53c1449..37c9e14 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -15,6 +15,9 @@ "ru.nevetime.clientirc.ClientIrcMod" ] }, + "mixins": [ + { "config": "clientirc.client.mixins.json", "environment": "client" } + ], "depends": { "fabricloader": ">=0.19.3", "minecraft": "26.2",