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.
This commit is contained in:
2026-06-21 21:16:32 +06:00
parent 0112bde962
commit c25e394d50
12 changed files with 864 additions and 66 deletions

View File

@@ -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}`));
}
});

View File

@@ -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 <message> | .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 <name> | .friends remove <name> | .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 <name>").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 <name>").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<String> 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 <msg> | .irc status | .irc reconnect | .irc reload").withStyle(ChatFormatting.GRAY));
pushClientChat(client, Component.literal(" .friends add <name> | .friends remove <name> | .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;

View File

@@ -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<String, Marker> 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<Marker> active() {
long now = System.currentTimeMillis();
ArrayList<Marker> alive = new ArrayList<>(MARKERS.size());
Iterator<Map.Entry<String, Marker>> it = MARKERS.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Marker> 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;
}
}

View File

@@ -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<IrcService> serviceSupplier, Supplier<String> 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<IrcService> serviceSupplier, Supplier<String> playerNameSupplier) {
String name = playerNameSupplier.get();
MarkerManager.remove(name);
IO_EXECUTOR.execute(() -> {
IrcService service = serviceSupplier.get();
if (service != null) service.sendMarkerRemove(name);
});
}
}

View File

@@ -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;
}
}

View File

@@ -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<MarkerManager.Marker> 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);
}
}

View File

@@ -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<Boolean> 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);
}
}
}

View File

@@ -0,0 +1,11 @@
{
"required": true,
"package": "ru.nevetime.clientirc.mixin",
"compatibilityLevel": "JAVA_21",
"client": [
"ChatScreenMixin"
],
"injectors": {
"defaultRequire": 1
}
}

View File

@@ -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<String, String> 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<String> list() {
List<String> 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<String> friends;
}
}

View File

@@ -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;
}
}

View File

@@ -19,7 +19,7 @@ public final class IrcService {
private static final Gson GSON = new Gson();
private final Logger logger;
private final ConcurrentLinkedQueue<DisplayLine> queue = new ConcurrentLinkedQueue<>();
private final ConcurrentLinkedQueue<IrcEvent> 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) {
}
}

View File

@@ -15,6 +15,9 @@
"ru.nevetime.clientirc.ClientIrcMod"
]
},
"mixins": [
{ "config": "clientirc.client.mixins.json", "environment": "client" }
],
"depends": {
"fabricloader": ">=0.19.3",
"minecraft": "26.2",