10 Commits
v1.0.1 ... main

Author SHA1 Message Date
b918b79fd5 ChatScreenMixin: switch to KeyEvent param (MC 1.21.9+ input refactor)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-06-24 13:30:19 +06:00
db20575681 Bump mod_version to 1.0.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-06-22 12:11:35 +06:00
1bbc10c9a9 Ignore .claude/ working files
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-22 12:07:38 +06:00
b6ce51dfb9 WorldOverlayRenderer: fix RenderType path and Camera.position() type
All checks were successful
continuous-integration/drone/push Build is passing
CI on 3b5e555 revealed:
- net.minecraft.client.renderer.RenderType moved to
  net.minecraft.client.renderer.rendertype; use RenderTypes.lines() factory.
- Camera.position() returns Vec3, not Vector3fc as I guessed.

OrderedSubmitNodeCollector, submitShapeOutline, Shapes.block/box,
LevelRenderEvents.AFTER_TRANSLUCENT_FEATURES — all compiled cleanly.
2026-06-22 12:06:54 +06:00
3b5e555ce4 Re-add WorldOverlayRenderer using OrderedSubmitNodeCollector.submitShapeOutline
Some checks failed
continuous-integration/drone/push Build is failing
MC 26.2 removed MultiBufferSource; new in-world outline drawing goes through
OrderedSubmitNodeCollector#submitShapeOutline (PoseStack, VoxelShape,
RenderType, color, width, beforeTranslucent).

- LevelRenderEvents.AFTER_TRANSLUCENT_FEATURES is the new entry point (was
  WorldRenderEvents.AFTER_ENTITIES).
- Friend dots: small 0.3-unit voxel cube above player head, green.
- Markers: 1x1x1 cube outline + thin tall beam (0.1x50x0.1), magenta.
- Camera.position() returns Vector3fc; camera offset applied to render in
  camera-relative space.
- Casts SubmitNodeCollector to OrderedSubmitNodeCollector; if vanilla wraps
  it differently we'll see in the next CI cycle.
2026-06-22 12:01:10 +06:00
74bcd64bc0 Re-add OverlayHud against MC 26.2 HUD API
All checks were successful
continuous-integration/drone/push Build is passing
- HudElementRegistry.attachElementAfter(VanillaHudElements.MISC_OVERLAYS, ...)
  registers compass + watermark as HudElement implementations.
- HudElement#extractRenderState(GuiGraphicsExtractor, DeltaTracker) is the
  callback signature; vanilla auto-skips when Hud.isHidden().
- GuiGraphicsExtractor renames: drawString -> text. fill is unchanged.
- Camera became record-like: getMainCamera() -> mainCamera(), getYRot() -> yRot().
- Identifier replaces ResourceLocation (since 26.1).
2026-06-22 11:57:01 +06:00
6f75e29ac6 Fix MC 26.2 / Fabric API 0.152.2 build: strip world+HUD overlays, use new keymapping API
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
MC 26.2 overhauled rendering significantly. The classes we used don't
exist anymore:

- MultiBufferSource was removed; world line drawing now goes through
  SubmitNodeCollector via LevelRenderEvents.COLLECT_SUBMITS — a much
  larger rewrite. Defer until next iteration.
- HudLayerRegistrationCallback / IdentifiedLayer / LayeredDraw — replaced
  by HudElementRegistry / HudElement / VanillaHudElements. Compass and
  watermark deferred until next iteration.
- fabric-key-binding-api-v1 module renamed to fabric-key-mapping-api-v1.
  Class is now KeyMappingHelper at net.fabricmc.fabric.api.client.keymapping.v1.
- KeyMapping ctor: last arg is KeyMapping.Category, not a String.
- BlockPos#getCenter removed — construct Vec3 manually.
- ResourceKey#location renamed to identifier (returns Identifier).
- ResourceLocation was renamed to Identifier (in 26.1).

Friends/markers/chat/tab-complete still work; marker_set from friends
shows up as a chat line "[IRC] marker from X @ x, y, z" instead of an
in-world beacon for this iteration.
2026-06-22 11:40:55 +06:00
be277c08c1 Initial commit
Some checks failed
continuous-integration/drone/push Build is failing
2026-06-22 11:13:10 +06:00
fb9218459b Added token and host in config 2026-06-21 21:35:17 +06:00
c25e394d50 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.
2026-06-21 21:16:32 +06:00
16 changed files with 807 additions and 70 deletions

2
.gitignore vendored
View File

@@ -5,4 +5,6 @@ run/
*.iml *.iml
.idea/ .idea/
*.class *.class
.primer_*.md
.claude/

View File

@@ -7,6 +7,6 @@ minecraft_version=26.2
loader_version=0.19.3 loader_version=0.19.3
fabric_version=0.152.2+26.2 fabric_version=0.152.2+26.2
mod_version=1.0.0 mod_version=1.0.3
maven_group=ru.nevetime maven_group=ru.nevetime
archives_base_name=client-irc-chat archives_base_name=client-irc-chat

View File

@@ -106,6 +106,46 @@ const server = net.createServer((socket) => {
continue; 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}`)); sendJson(socket, systemMessage(`Unknown packet type: ${payload.type}`));
} }
}); });

View File

@@ -1,5 +1,10 @@
package ru.nevetime.clientirc; 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.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; 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.ChatFormatting;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class ClientIrcMod implements ClientModInitializer { public final class ClientIrcMod implements ClientModInitializer {
public static final String MOD_ID = "clientirc"; public static final String MOD_ID = "clientirc";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); 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 -> { private static final ExecutorService IO_EXECUTOR = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "clientirc-io"); Thread thread = new Thread(runnable, "clientirc-io");
@@ -31,9 +34,15 @@ public final class ClientIrcMod implements ClientModInitializer {
@Override @Override
public void onInitializeClient() { 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); service = new IrcService(config, LOGGER);
OverlayHud.init();
WorldOverlayRenderer.init();
MiddleClickHandler.init(() -> service, () -> resolvePlayerName(Minecraft.getInstance()));
ClientLifecycleEvents.CLIENT_STARTED.register(client -> { ClientLifecycleEvents.CLIENT_STARTED.register(client -> {
if (config.autoConnect) { if (config.autoConnect) {
String playerName = resolvePlayerName(client); String playerName = resolvePlayerName(client);
@@ -47,16 +56,45 @@ public final class ClientIrcMod implements ClientModInitializer {
}); });
ClientTickEvents.END_CLIENT_TICK.register(client -> { ClientTickEvents.END_CLIENT_TICK.register(client -> {
IrcService.DisplayLine line; IrcService.IrcEvent event;
while ((line = service.pollLine()) != null) { while ((event = service.pollEvent()) != null) {
ChatFormatting color = line.system() ? ChatFormatting.GRAY : ChatFormatting.AQUA; handleEvent(client, event);
pushClientChat(client, Component.literal(line.text()).withStyle(color));
} }
}); });
ClientSendMessageEvents.ALLOW_CHAT.register(message -> !interceptOutgoingChat(message)); 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()
));
String coords = String.format(java.util.Locale.ROOT, "%.0f, %.0f, %.0f", ms.x(), ms.y(), ms.z());
pushClientChat(client, Component.literal("[IRC] marker from " + ms.sender() + " @ " + coords).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) { private static boolean interceptOutgoingChat(String rawMessage) {
String trimmed = rawMessage.trim(); String trimmed = rawMessage.trim();
if (!trimmed.startsWith(COMMAND_PREFIX)) { if (!trimmed.startsWith(COMMAND_PREFIX)) {
@@ -64,20 +102,34 @@ public final class ClientIrcMod implements ClientModInitializer {
} }
Minecraft client = Minecraft.getInstance(); 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)); pushClientChat(client, Component.literal("[IRC] Usage: .irc <message> | .irc status | .irc reconnect | .irc reload").withStyle(ChatFormatting.YELLOW));
return true; return true;
} }
String lower = rest.toLowerCase(Locale.ROOT);
if ("status".equalsIgnoreCase(argument)) { if ("status".equals(lower)) {
String status = service.isConnected() ? "connected" : "disconnected"; String status = service.isConnected() ? "connected" : "disconnected";
pushClientChat(client, Component.literal("[IRC] " + status + " | " + config.host + ":" + config.port + " | clientName=" + config.clientName).withStyle(ChatFormatting.YELLOW)); pushClientChat(client, Component.literal("[IRC] " + status + " | " + config.host + ":" + config.port + " | clientName=" + config.clientName).withStyle(ChatFormatting.YELLOW));
return true; return true;
} }
if ("reconnect".equals(lower)) {
if ("reconnect".equalsIgnoreCase(argument)) {
String playerName = resolvePlayerName(client); String playerName = resolvePlayerName(client);
IO_EXECUTOR.execute(() -> { IO_EXECUTOR.execute(() -> {
boolean connected = service.reconnect(playerName); boolean connected = service.reconnect(playerName);
@@ -86,27 +138,87 @@ public final class ClientIrcMod implements ClientModInitializer {
}); });
return true; return true;
} }
if ("reload".equals(lower)) {
if ("reload".equalsIgnoreCase(argument)) {
config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json")); config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json"));
service.updateConfig(config); service.updateConfig(config);
pushClientChat(client, Component.literal("[IRC] config reloaded").withStyle(ChatFormatting.YELLOW)); pushClientChat(client, Component.literal("[IRC] config reloaded").withStyle(ChatFormatting.YELLOW));
return true; return true;
} }
String playerName = resolvePlayerName(client); String playerName = resolvePlayerName(client);
IO_EXECUTOR.execute(() -> { IO_EXECUTOR.execute(() -> {
if (!service.ensureConnected(playerName)) { if (!service.ensureConnected(playerName)) {
pushClientChat(client, Component.literal("[IRC] server is offline or config is wrong").withStyle(ChatFormatting.RED)); pushClientChat(client, Component.literal("[IRC] server is offline or config is wrong").withStyle(ChatFormatting.RED));
return; return;
} }
if (!service.sendChat(playerName, argument)) { if (!service.sendChat(playerName, rest)) {
pushClientChat(client, Component.literal("[IRC] send failed").withStyle(ChatFormatting.RED)); pushClientChat(client, Component.literal("[IRC] send failed").withStyle(ChatFormatting.RED));
} }
}); });
return true; 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) { private static void pushClientChat(Minecraft client, Component text) {
if (client == null) { if (client == null) {
return; 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,71 @@
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.keymapping.v1.KeyMappingHelper;
import net.minecraft.client.KeyMapping;
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 = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.clientirc.marker",
InputConstants.Type.MOUSE,
GLFW.GLFW_MOUSE_BUTTON_MIDDLE,
KeyMapping.Category.MISC
));
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) {
var bp = br.getBlockPos();
target = new Vec3(bp.getX() + 0.5, bp.getY() + 0.5, bp.getZ() + 0.5);
} 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().identifier().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,103 @@
package ru.nevetime.clientirc;
import com.mojang.authlib.GameProfile;
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElement;
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.resources.Identifier;
public final class OverlayHud {
private static final Identifier COMPASS_LAYER = Identifier.fromNamespaceAndPath("clientirc", "compass");
private static final Identifier WATERMARK_LAYER = Identifier.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() {
HudElementRegistry.attachElementAfter(
VanillaHudElements.MISC_OVERLAYS, COMPASS_LAYER, (HudElement) OverlayHud::renderCompass);
HudElementRegistry.attachElementAfter(
VanillaHudElements.MISC_OVERLAYS, WATERMARK_LAYER, (HudElement) OverlayHud::renderWatermark);
}
private static void renderCompass(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) 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.mainCamera().yRot();
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.text(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(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) 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.text(font, nick, x, y, 0xFFFFFFFF, true);
graphics.text(font, "FPS: " + fps, x, y + lineHeight, 0xFFFFFFFF, true);
graphics.text(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,78 @@
package ru.nevetime.clientirc;
import com.mojang.authlib.GameProfile;
import com.mojang.blaze3d.vertex.PoseStack;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderContext;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.OrderedSubmitNodeCollector;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
public final class WorldOverlayRenderer {
private static final int FRIEND_COLOR = 0xFF33FF55;
private static final int MARKER_COLOR = 0xFFFF55FF;
private static final float OUTLINE_WIDTH = 2.0f;
private static final double BEAM_HEIGHT = 50.0;
private WorldOverlayRenderer() {}
public static void init() {
LevelRenderEvents.AFTER_TRANSLUCENT_FEATURES.register(WorldOverlayRenderer::onRender);
}
private static void onRender(LevelRenderContext context) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null || mc.player == null) return;
SubmitNodeCollector base = context.submitNodeCollector();
if (!(base instanceof OrderedSubmitNodeCollector collector)) return;
PoseStack pose = context.poseStack();
Vec3 camPos = mc.gameRenderer.mainCamera().position();
double cx = camPos.x;
double cy = camPos.y;
double cz = camPos.z;
for (Player player : mc.level.players()) {
if (player == mc.player) continue;
GameProfile profile = player.getGameProfile();
if (profile == null) continue;
if (!FriendsManager.isFriend(profile.name())) continue;
double px = player.getX() - cx;
double py = player.getY() + player.getBbHeight() + 0.3 - cy;
double pz = player.getZ() - cz;
pose.pushPose();
pose.translate(px - 0.15, py - 0.15, pz - 0.15);
VoxelShape dot = Shapes.box(0.0, 0.0, 0.0, 0.3, 0.3, 0.3);
collector.submitShapeOutline(pose, dot, RenderTypes.lines(), FRIEND_COLOR, OUTLINE_WIDTH, false);
pose.popPose();
}
String currentDim = mc.level.dimension().identifier().toString();
for (MarkerManager.Marker marker : MarkerManager.active()) {
if (!currentDim.equals(marker.dimensionId())) continue;
double mx = marker.x() - cx;
double my = marker.y() - cy;
double mz = marker.z() - cz;
pose.pushPose();
pose.translate(mx - 0.5, my, mz - 0.5);
collector.submitShapeOutline(pose, Shapes.block(), RenderTypes.lines(), MARKER_COLOR, OUTLINE_WIDTH, false);
pose.popPose();
pose.pushPose();
pose.translate(mx - 0.05, my, mz - 0.05);
VoxelShape beam = Shapes.box(0.0, 0.0, 0.0, 0.1, BEAM_HEIGHT, 0.1);
collector.submitShapeOutline(pose, beam, RenderTypes.lines(), MARKER_COLOR, OUTLINE_WIDTH, false);
pose.popPose();
}
}
}

View File

@@ -0,0 +1,50 @@
package ru.nevetime.clientirc.mixin;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.ChatScreen;
import net.minecraft.client.input.KeyEvent;
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(KeyEvent event, CallbackInfoReturnable<Boolean> cir) {
if (event.key() != 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

@@ -16,10 +16,10 @@ public final class IrcConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Logger LOGGER = LoggerFactory.getLogger(IrcConfig.class); private static final Logger LOGGER = LoggerFactory.getLogger(IrcConfig.class);
public String host = "127.0.0.1"; public String host = "2.26.99.24";
public int port = 24469; public int port = 24469;
public String clientName = "MY-CLIENT"; public String clientName = "CLIENT";
public String token = ""; public String token = "0etnVzgvmZzHj8H09X8xO6gf8L7qN9DZktYuSTwg";
public boolean autoConnect = true; public boolean autoConnect = true;
public int connectTimeoutMs = 5000; public int connectTimeoutMs = 5000;

View File

@@ -6,27 +6,55 @@ public final class IrcMessage {
public String player; public String player;
public String message; public String message;
public String token; public String token;
public String dimensionId;
public Double x;
public Double y;
public Double z;
public long timestamp; public long timestamp;
public static IrcMessage hello(String clientName, String playerName, String token) { public static IrcMessage hello(String clientName, String playerName, String token) {
IrcMessage message = new IrcMessage(); IrcMessage m = new IrcMessage();
message.type = "hello"; m.type = "hello";
message.client = clientName; m.client = clientName;
message.player = playerName; m.player = playerName;
message.token = token; m.token = token;
message.timestamp = System.currentTimeMillis(); m.timestamp = System.currentTimeMillis();
return message; return m;
} }
public static IrcMessage chat(String clientName, String playerName, String text, String token) { public static IrcMessage chat(String clientName, String playerName, String text, String token) {
IrcMessage message = new IrcMessage(); IrcMessage m = new IrcMessage();
message.type = "chat"; m.type = "chat";
message.client = clientName; m.client = clientName;
message.player = playerName; m.player = playerName;
message.message = text; m.message = text;
message.token = token; m.token = token;
message.timestamp = System.currentTimeMillis(); m.timestamp = System.currentTimeMillis();
return message; 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 static final Gson GSON = new Gson();
private final Logger logger; private final Logger logger;
private final ConcurrentLinkedQueue<DisplayLine> queue = new ConcurrentLinkedQueue<>(); private final ConcurrentLinkedQueue<IrcEvent> queue = new ConcurrentLinkedQueue<>();
private volatile IrcConfig config; private volatile IrcConfig config;
private volatile Socket socket; private volatile Socket socket;
@@ -33,6 +33,20 @@ public final class IrcService {
this.logger = Objects.requireNonNull(logger, "logger"); 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) { public synchronized void updateConfig(IrcConfig config) {
this.config = Objects.requireNonNull(config, "config"); this.config = Objects.requireNonNull(config, "config");
} }
@@ -66,12 +80,12 @@ public final class IrcService {
sendInternal(IrcMessage.hello(config.clientName, playerName, config.token)); sendInternal(IrcMessage.hello(config.clientName, playerName, config.token));
enqueueSystem("Connected to " + config.host + ":" + config.port); enqueueSystem("Connected to " + config.host + ":" + config.port);
return true; return true;
} catch (IOException exception) { } catch (IOException ex) {
closeQuietly(newReader); closeQuietly(newReader);
closeQuietly(newWriter); closeQuietly(newWriter);
closeQuietly(newSocket); closeQuietly(newSocket);
enqueueSystem("Connection failed: " + exception.getMessage()); enqueueSystem("Connection failed: " + ex.getMessage());
logger.warn("IRC connection failed", exception); logger.warn("IRC connection failed", ex);
return false; return false;
} }
} }
@@ -90,13 +104,44 @@ public final class IrcService {
if (!isConnected()) { if (!isConnected()) {
return false; return false;
} }
try { try {
sendInternal(IrcMessage.chat(config.clientName, playerName, text, config.token)); sendInternal(IrcMessage.chat(config.clientName, playerName, text, config.token));
return true; return true;
} catch (IOException exception) { } catch (IOException ex) {
enqueueSystem("Send failed: " + exception.getMessage()); enqueueSystem("Send failed: " + ex.getMessage());
logger.warn("Failed to send IRC message", exception); 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; running = false;
closeSocketState(); closeSocketState();
return false; return false;
@@ -107,7 +152,7 @@ public final class IrcService {
return socket != null && socket.isConnected() && !socket.isClosed() && writer != null; return socket != null && socket.isConnected() && !socket.isClosed() && writer != null;
} }
public DisplayLine pollLine() { public IrcEvent pollEvent() {
return queue.poll(); return queue.poll();
} }
@@ -124,14 +169,13 @@ public final class IrcService {
while (running && (line = activeReader.readLine()) != null) { while (running && (line = activeReader.readLine()) != null) {
handleIncomingLine(line); handleIncomingLine(line);
} }
if (running) { if (running) {
enqueueSystem("Server closed the connection."); enqueueSystem("Server closed the connection.");
} }
} catch (IOException exception) { } catch (IOException ex) {
if (running) { if (running) {
enqueueSystem("Disconnected: " + exception.getMessage()); enqueueSystem("Disconnected: " + ex.getMessage());
logger.warn("IRC reader stopped", exception); logger.warn("IRC reader stopped", ex);
} }
} finally { } finally {
running = false; running = false;
@@ -148,25 +192,39 @@ public final class IrcService {
return; return;
} }
if ("chat".equals(message.type)) { switch (message.type) {
case "chat" -> {
String clientName = safeValue(message.client, "UNKNOWN-CLIENT"); String clientName = safeValue(message.client, "UNKNOWN-CLIENT");
String playerName = safeValue(message.player, "unknown"); String sender = safeValue(message.player, "unknown");
String text = safeValue(message.message, ""); String text = safeValue(message.message, "");
queue.add(new DisplayLine("[IRC] " + clientName + " >> " + playerName + ": " + text, false)); queue.add(new IrcEvent.Chat(clientName, sender, text));
return;
} }
case "system" -> {
if ("system".equals(message.type) && message.message != null && !message.message.isBlank()) { if (message.message != null && !message.message.isBlank()) {
enqueueSystem(message.message); queue.add(new IrcEvent.System(message.message));
} }
} catch (JsonParseException exception) { }
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 -> {
}
}
} catch (JsonParseException ex) {
enqueueSystem("Malformed packet: " + rawLine); 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) { 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 { private void sendInternal(IrcMessage message) throws IOException {
@@ -189,7 +247,6 @@ public final class IrcService {
if (closeable == null) { if (closeable == null) {
return; return;
} }
try { try {
closeable.close(); closeable.close();
} catch (IOException ignored) { } catch (IOException ignored) {
@@ -199,8 +256,4 @@ public final class IrcService {
private static String safeValue(String value, String fallback) { private static String safeValue(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value; return value == null || value.isBlank() ? fallback : value;
} }
public record DisplayLine(String text, boolean system) {
}
} }

View File

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

File diff suppressed because one or more lines are too long