From a4d2fc5f5eb5e09bbad74c1203b3cf008b923fe5 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Wed, 12 Aug 2026 09:54:33 +0200 Subject: [PATCH] feat: implement /rules command with custom networking payload --- .../examp/john/client/LocalchatClient.java | 6 +- .../client/rules/RulesClientNetworking.java | 19 +++ .../examp/john/client/rules/RulesScreen.java | 145 ++++++++++++++++++ src/main/java/org/examp/john/ChatLogger.java | 5 - src/main/java/org/examp/john/Localchat.java | 7 +- .../org/examp/john/rules/RulesCommand.java | 43 ++++++ .../java/org/examp/john/rules/RulesData.java | 80 ++++++++++ .../org/examp/john/rules/RulesLoader.java | 78 ++++++++++ .../org/examp/john/rules/RulesNetworking.java | 16 ++ .../org/examp/john/rules/RulesPayload.java | 42 +++++ 10 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 src/client/java/org/examp/john/client/rules/RulesClientNetworking.java create mode 100644 src/client/java/org/examp/john/client/rules/RulesScreen.java create mode 100644 src/main/java/org/examp/john/rules/RulesCommand.java create mode 100644 src/main/java/org/examp/john/rules/RulesData.java create mode 100644 src/main/java/org/examp/john/rules/RulesLoader.java create mode 100644 src/main/java/org/examp/john/rules/RulesNetworking.java create mode 100644 src/main/java/org/examp/john/rules/RulesPayload.java diff --git a/src/client/java/org/examp/john/client/LocalchatClient.java b/src/client/java/org/examp/john/client/LocalchatClient.java index 55ece85..a742344 100644 --- a/src/client/java/org/examp/john/client/LocalchatClient.java +++ b/src/client/java/org/examp/john/client/LocalchatClient.java @@ -1,10 +1,12 @@ package org.examp.john.client; import net.fabricmc.api.ClientModInitializer; +import org.examp.john.client.rules.RulesClientNetworking; +import org.examp.john.rules.RulesNetworking; public class LocalchatClient implements ClientModInitializer { - @Override public void onInitializeClient() { + RulesClientNetworking.register(); } -} +} \ No newline at end of file diff --git a/src/client/java/org/examp/john/client/rules/RulesClientNetworking.java b/src/client/java/org/examp/john/client/rules/RulesClientNetworking.java new file mode 100644 index 0000000..2819848 --- /dev/null +++ b/src/client/java/org/examp/john/client/rules/RulesClientNetworking.java @@ -0,0 +1,19 @@ +package org.examp.john.client.rules; + +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.Minecraft; +import org.examp.john.rules.RulesData; +import org.examp.john.rules.RulesPayload; + +public class RulesClientNetworking { + + public static void register() { + ClientPlayNetworking.registerGlobalReceiver(RulesPayload.TYPE, (payload, context) -> { + RulesData.RulesRoot data = payload.toData(); + + context.client().execute(() -> + Minecraft.getInstance().setScreen(new RulesScreen(data)) + ); + }); + } +} \ No newline at end of file diff --git a/src/client/java/org/examp/john/client/rules/RulesScreen.java b/src/client/java/org/examp/john/client/rules/RulesScreen.java new file mode 100644 index 0000000..94c9411 --- /dev/null +++ b/src/client/java/org/examp/john/client/rules/RulesScreen.java @@ -0,0 +1,145 @@ +package org.examp.john.client.rules; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.examp.john.rules.RulesData; + +import java.util.List; + +/** + * GUI екран правил серверу. Зверху ряд кнопок-вкладок (по одній на кожен розділ), + * знизу список пунктів обраного розділу з кольоровим трикутником-значком + * (зелений/жовтий/червоний) залежно від тяжкості порушення. + * + * Дані (розділи, пункти, нумерація, кольори) повністю визначаються rules.json + * на сервері — цей клас нічого не хардкодить, окрім розмірів/відступів GUI. + * + * У Minecraft 26.1.2+ Mojang перейменували GuiGraphics на GuiGraphicsExtractor + * (джерело: docs.fabricmc.net/develop/rendering/gui-graphics). + */ +public class RulesScreen extends Screen { + + // ARGB кольори. З Minecraft 1.21.6+ текстовий колір інтерпретується як ARGB, + // тому альфа-канал (FF) обов'язковий, інакше текст буде прозорим/невидимим. + private static final int COLOR_WHITE = 0xFFFFFFFF; + private static final int COLOR_GRAY = 0xFFAAAAAA; + private static final int COLOR_BG_PANEL = 0xCC101010; + private static final int COLOR_TAB_ACTIVE = 0xFF3A3A3A; + private static final int COLOR_TAB_INACTIVE = 0xFF202020; + + private static final int TAB_HEIGHT = 20; + private static final int TAB_WIDTH = 100; + private static final int ENTRY_HEIGHT = 24; + private static final int PADDING = 8; + + private final RulesData.RulesRoot rulesData; + private int selectedSection = 0; + private int scrollOffset = 0; + + public RulesScreen(RulesData.RulesRoot rulesData) { + super(Component.literal("Правила серверу")); + this.rulesData = rulesData; + } + + @Override + protected void init() { + super.init(); + clearWidgets(); + + if (rulesData == null || rulesData.sections == null || rulesData.sections.isEmpty()) { + return; + } + + int tabX = PADDING; + int tabY = PADDING + 20; + + for (int i = 0; i < rulesData.sections.size(); i++) { + final int index = i; + RulesData.Section section = rulesData.sections.get(i); + + Button tabButton = Button.builder( + Component.literal(section.title), + button -> { + selectedSection = index; + scrollOffset = 0; + } + ).bounds(tabX, tabY, TAB_WIDTH, TAB_HEIGHT).build(); + + addRenderableWidget(tabButton); + + tabX += TAB_WIDTH + 4; + // Переносимо на новий ряд вкладок, якщо не влазить по ширині екрану + if (tabX + TAB_WIDTH > this.width - PADDING) { + tabX = PADDING; + tabY += TAB_HEIGHT + 4; + } + } + + // Кнопка закриття внизу екрану + addRenderableWidget( + Button.builder(Component.literal("Закрити"), button -> onClose()) + .bounds(this.width / 2 - 50, this.height - PADDING - 20, 100, 20) + .build() + ); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta) { + super.extractRenderState(graphics, mouseX, mouseY, delta); + + graphics.centeredText(this.font, this.title, this.width / 2, PADDING, COLOR_WHITE); + + if (rulesData == null || rulesData.sections == null || rulesData.sections.isEmpty()) { + graphics.centeredText( + this.font, + Component.literal("Правила ще не завантажені."), + this.width / 2, this.height / 2, COLOR_GRAY + ); + return; + } + + renderEntries(graphics); + } + + private void renderEntries(GuiGraphicsExtractor graphics) { + RulesData.Section section = rulesData.sections.get(selectedSection); + List entries = section.entries; + + if (entries == null || entries.isEmpty()) { + return; + } + + int panelTop = PADDING + 50; + int panelBottom = this.height - PADDING - 30; + int panelLeft = PADDING; + int panelRight = this.width - PADDING; + + graphics.fill(panelLeft, panelTop, panelRight, panelBottom, COLOR_BG_PANEL); + + int y = panelTop + 6 - scrollOffset; + + for (RulesData.Entry entry : entries) { + if (y + ENTRY_HEIGHT < panelTop || y > panelBottom) { + y += ENTRY_HEIGHT; + continue; + } + + // Кольоровий трикутник-значок тяжкості порушення + String triangle = "\u26A0"; // ⚠ + graphics.text(this.font, triangle, panelLeft + 6, y, entry.severity.color | 0xFF000000, false); + + // Номер пункту + текст + String line = entry.number + ". " + entry.text; + graphics.text(this.font, line, panelLeft + 24, y, COLOR_WHITE, false); + + y += ENTRY_HEIGHT; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/org/examp/john/ChatLogger.java b/src/main/java/org/examp/john/ChatLogger.java index 9fd979c..14adf56 100644 --- a/src/main/java/org/examp/john/ChatLogger.java +++ b/src/main/java/org/examp/john/ChatLogger.java @@ -13,7 +13,6 @@ import java.util.stream.Collectors; public class ChatLogger { public static void logGlobal(ServerPlayer sender, String message, List recipients) { - // Для глобального чату ігноруємо список отримувачів і просто пишемо "everyone" Localchat.magicModLog.info( "[CHAT][GLOBAL] {} said: \"{}\" | heard by: everyone | pos: {}", sender.getName().getString(), @@ -23,7 +22,6 @@ public class ChatLogger { } public static void logLocal(ServerPlayer sender, String message, List recipients) { - // Для локального чату виводимо лише імена отримувачів, без їхніх координат String heardBy = recipients.isEmpty() ? "nobody" : recipients.stream() @@ -50,9 +48,6 @@ public class ChatLogger { private static String formatPos(ServerPlayer player) { ServerLevel level = player.level(); - - // У ResourceKey дістаємо Identifier/ResourceLocation через .identifier(), - // після чого через .getPath() беремо чисту назву ("overworld", "the_nether", "the_end") String rawDimension = level.dimension().identifier().getPath(); return String.format( diff --git a/src/main/java/org/examp/john/Localchat.java b/src/main/java/org/examp/john/Localchat.java index d33b0f5..be36627 100644 --- a/src/main/java/org/examp/john/Localchat.java +++ b/src/main/java/org/examp/john/Localchat.java @@ -1,6 +1,8 @@ package org.examp.john; import net.fabricmc.api.ModInitializer; +import org.examp.john.rules.RulesCommand; +import org.examp.john.rules.RulesNetworking; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,7 +12,10 @@ public class Localchat implements ModInitializer { @Override public void onInitialize() { - // Тепер ChatHandler викликається з того ж пакета без помилок імпорту ChatHandler.register(); + + // Реєстрація мережі /rules (спільна частина: обов'язково і на сервері, і на клієнті) + RulesNetworking.registerCommon(); + RulesCommand.register(); } } diff --git a/src/main/java/org/examp/john/rules/RulesCommand.java b/src/main/java/org/examp/john/rules/RulesCommand.java new file mode 100644 index 0000000..94c5aca --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesCommand.java @@ -0,0 +1,43 @@ +package org.examp.john.rules; + +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +/** + * Реєструє команду /rules. Гравець виконує команду -> сервер читає rules.json + * -> надсилає RulesPayload через мережу -> клієнт відкриває RulesScreen (кастомний GUI з вкладками). + */ +public class RulesCommand { + + public static void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> + dispatcher.register(buildCommand()) + ); + } + + private static LiteralArgumentBuilder buildCommand() { + return Commands.literal("rules").executes(RulesCommand::execute); + } + + private static int execute(CommandContext context) { + CommandSourceStack source = context.getSource(); + + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Цю команду можна викликати лише в грі.")); + return 0; + } + + RulesData.RulesRoot rules = RulesLoader.get(); + RulesPayload payload = RulesPayload.fromData(rules); + + ServerPlayNetworking.send(player, payload); + + return 1; + } +} diff --git a/src/main/java/org/examp/john/rules/RulesData.java b/src/main/java/org/examp/john/rules/RulesData.java new file mode 100644 index 0000000..2250f1a --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesData.java @@ -0,0 +1,80 @@ +package org.examp.john.rules; + +import java.util.List; + +/** + * Моделі даних для системи правил сервера. + * Структура JSON конфігу: + * { + * "sections": [ + * { + * "title": "Правила будівництва", + * "entries": [ + * { "number": "1.1", "text": "Заборонено будувати без дозволу...", "severity": "MEDIUM" } + * ] + * } + * ] + * } + */ +public class RulesData { + + /** + * Рівень тяжкості порушення. Визначає колір трикутника-значка в GUI. + * LIGHT -> зелений + * MEDIUM -> жовтий + * HEAVY -> червоний + */ + public enum Severity { + LIGHT(0x55FF55), // зелений + MEDIUM(0xFFFF55), // жовтий + HEAVY(0xFF5555); // червоний + + public final int color; + + Severity(int color) { + this.color = color; + } + + public static Severity fromString(String raw) { + if (raw == null) return LIGHT; + try { + return Severity.valueOf(raw.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return LIGHT; + } + } + } + + /** Один пункт правила всередині розділу. */ + public static class Entry { + public String number; // напр. "1.1" або "IV" + public String text; // текст правила / опис порушення + public Severity severity; + + public Entry(String number, String text, Severity severity) { + this.number = number; + this.text = text; + this.severity = severity; + } + } + + /** Розділ правил (вкладка в GUI), напр. "Правила будівництва". */ + public static class Section { + public String title; + public List entries; + + public Section(String title, List entries) { + this.title = title; + this.entries = entries; + } + } + + /** Корінь конфігу — список усіх розділів. */ + public static class RulesRoot { + public List
sections; + + public RulesRoot(List
sections) { + this.sections = sections; + } + } +} diff --git a/src/main/java/org/examp/john/rules/RulesLoader.java b/src/main/java/org/examp/john/rules/RulesLoader.java new file mode 100644 index 0000000..e6d47f6 --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesLoader.java @@ -0,0 +1,78 @@ +package org.examp.john.rules; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import org.examp.john.Localchat; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +/** + * Завантажує та кешує rules.json з папки config мода. + * Файл лежить у config/localchat/rules.json — адміни можуть редагувати + * без перезбирання мода, зміни підхоплюються після /rules reload (додамо пізніше) + * або перезапуску сервера. + */ +public class RulesLoader { + + private static final Gson GSON = new GsonBuilder().create(); + private static final Path CONFIG_PATH = Path.of("config", "localchat", "rules.json"); + + private static RulesData.RulesRoot cached; + + /** Повертає закешовані правила, або завантажує їх з диску при першому виклику. */ + public static RulesData.RulesRoot get() { + if (cached == null) { + cached = load(); + } + return cached; + } + + /** Примусово перечитує файл з диску (для майбутньої команди /rules reload). */ + public static RulesData.RulesRoot reload() { + cached = load(); + return cached; + } + + private static RulesData.RulesRoot load() { + try { + if (!Files.exists(CONFIG_PATH)) { + createDefaultConfig(); + } + + try (Reader reader = Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8)) { + RulesData.RulesRoot root = GSON.fromJson(reader, RulesData.RulesRoot.class); + if (root == null || root.sections == null) { + Localchat.magicModLog.warn("rules.json порожній або невалідний, використовую пустий список правил"); + return new RulesData.RulesRoot(Collections.emptyList()); + } + return root; + } + } catch (IOException e) { + Localchat.magicModLog.error("Не вдалося прочитати rules.json: {}", e.getMessage()); + return new RulesData.RulesRoot(Collections.emptyList()); + } catch (JsonSyntaxException e) { + Localchat.magicModLog.error("rules.json містить синтаксичну помилку JSON: {}", e.getMessage()); + return new RulesData.RulesRoot(Collections.emptyList()); + } + } + + private static void createDefaultConfig() throws IOException { + Files.createDirectories(CONFIG_PATH.getParent()); + + RulesData.Entry defaultEntry = new RulesData.Entry("1.1", "Пункт 1", RulesData.Severity.LIGHT); + RulesData.Section defaultSection = new RulesData.Section("Правила", Collections.singletonList(defaultEntry)); + RulesData.RulesRoot defaultRoot = new RulesData.RulesRoot(Collections.singletonList(defaultSection)); + + try (var writer = Files.newBufferedWriter(CONFIG_PATH, StandardCharsets.UTF_8)) { + new GsonBuilder().setPrettyPrinting().create().toJson(defaultRoot, writer); + } + + Localchat.magicModLog.info("Створено конфіг за замовчуванням: {}", CONFIG_PATH.toAbsolutePath()); + } +} diff --git a/src/main/java/org/examp/john/rules/RulesNetworking.java b/src/main/java/org/examp/john/rules/RulesNetworking.java new file mode 100644 index 0000000..271cab4 --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesNetworking.java @@ -0,0 +1,16 @@ +package org.examp.john.rules; + +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; + +/** + * Реєструє тип пакета RulesPayload. Викликається і на клієнті, і на сервері + * (обов'язкова вимога Fabric API: обидві сторони мають знати про CustomPayload). + */ +public class RulesNetworking { + + public static void registerCommon() { + // У Fabric API 26.1+ playS2C() перейменували на clientboundPlay() + // (офіційний гайд порту: docs.fabricmc.net/develop/porting/fabric-api). + PayloadTypeRegistry.clientboundPlay().register(RulesPayload.TYPE, RulesPayload.CODEC); + } +} \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesPayload.java b/src/main/java/org/examp/john/rules/RulesPayload.java new file mode 100644 index 0000000..d47df5f --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesPayload.java @@ -0,0 +1,42 @@ +package org.examp.john.rules; + +import com.google.gson.Gson; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import org.examp.john.Localchat; + +/** + * Пакет сервер -> клієнт, що передає весь список правил у вигляді JSON-рядка. + * Це найпростіший спосіб не писати окремий StreamCodec для кожного поля + * RulesData: серіалізуємо через Gson в один String і десеріалізуємо назад на клієнті. + */ +public record RulesPayload(String rulesJson) implements CustomPacketPayload { + + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(Localchat.MOD_ID, "rules_payload")); + + public static final StreamCodec CODEC = + StreamCodec.of( + (buf, payload) -> buf.writeUtf(payload.rulesJson(), Short.MAX_VALUE), + buf -> new RulesPayload(buf.readUtf(Short.MAX_VALUE)) + ); + + @Override + public Type type() { + return TYPE; + } + + /** Створює payload з поточних правил, серіалізуючи їх у JSON. */ + public static RulesPayload fromData(RulesData.RulesRoot data) { + Gson gson = new Gson(); + return new RulesPayload(gson.toJson(data)); + } + + /** Десеріалізує JSON назад у RulesData на клієнтській стороні. */ + public RulesData.RulesRoot toData() { + Gson gson = new Gson(); + return gson.fromJson(rulesJson, RulesData.RulesRoot.class); + } +} \ No newline at end of file