diff --git a/org/examp/john/rules/RulesCommand.class b/org/examp/john/rules/RulesCommand.class new file mode 100644 index 0000000..ea12c27 Binary files /dev/null and b/org/examp/john/rules/RulesCommand.class differ diff --git a/src/client/java/org/examp/john/mixin/client/PlayerTabOverlayMixin.java b/src/client/java/org/examp/john/mixin/client/PlayerTabOverlayMixin.java new file mode 100644 index 0000000..ca8c6a8 --- /dev/null +++ b/src/client/java/org/examp/john/mixin/client/PlayerTabOverlayMixin.java @@ -0,0 +1,49 @@ +package org.examp.john.mixin.client; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.PlayerTabOverlay; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.network.chat.Component; +import net.minecraft.world.level.GameType; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Прибирає курсив та сірий колір для spectator у таб-листі. + */ +@Mixin(PlayerTabOverlay.class) +public class PlayerTabOverlayMixin { + + /** + * Прибирає курсив з назви spectator в табі. + */ + @Inject(method = "getNameForDisplay", at = @At("RETURN"), cancellable = true) + private void removeSpectatorItalic(PlayerInfo playerInfo, CallbackInfoReturnable cir) { + if (playerInfo.getGameMode() == GameType.SPECTATOR) { + Component original = cir.getReturnValue(); + cir.setReturnValue(original.copy().withStyle(original.getStyle().withItalic(false))); + } + } + + /** + * Перехоплюємо виклик text(Font, Component, x, y, color) в extractRenderState. + * Якщо колір = -1862270977 (сірий для spectator), замінюємо на -1 (білий). + */ + @Redirect( + method = "extractRenderState", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;text(Lnet/minecraft/client/gui/Font;Lnet/minecraft/network/chat/Component;III)V" + ) + ) + private void forceWhiteText(GuiGraphicsExtractor instance, Font font, Component text, int x, int y, int color) { + if (color == -1862270977) { + color = -1; // білий + } + instance.text(font, text, x, y, color); + } +} \ No newline at end of file diff --git a/src/client/resources/localchat.client.mixins.json b/src/client/resources/localchat.client.mixins.json index b19dc56..f4006f2 100644 --- a/src/client/resources/localchat.client.mixins.json +++ b/src/client/resources/localchat.client.mixins.json @@ -4,6 +4,7 @@ "package": "org.examp.john.mixin.client", "compatibilityLevel": "JAVA_25", "client": [ + "PlayerTabOverlayMixin" ], "injectors": { "defaultRequire": 1 diff --git a/src/main/java/org/examp/john/Localchat.java b/src/main/java/org/examp/john/Localchat.java index b37a737..37321c4 100644 --- a/src/main/java/org/examp/john/Localchat.java +++ b/src/main/java/org/examp/john/Localchat.java @@ -1,6 +1,7 @@ package org.examp.john; import net.fabricmc.api.ModInitializer; +import org.examp.john.rules.RulesCommand; import org.examp.john.stealth.StealthCommand; import org.examp.john.stealth.StealthStorage; import org.slf4j.Logger; @@ -15,5 +16,6 @@ public class Localchat implements ModInitializer { StealthStorage.load(); ChatHandler.register(); StealthCommand.register(); + RulesCommand.register(); } } diff --git a/src/main/java/org/examp/john/rules/RulesCommand.java b/src/main/java/org/examp/john/rules/RulesCommand.java index e5ffa14..a0e2f1e 100644 --- a/src/main/java/org/examp/john/rules/RulesCommand.java +++ b/src/main/java/org/examp/john/rules/RulesCommand.java @@ -22,11 +22,9 @@ import java.util.Map; import java.util.UUID; /** - * Реєструє команду /rules. Повністю server-side: не потребує жодного мода - * на клієнті гравця. Книга створюється програмно через Data Components API - * і відкривається через openItemGui (ClientboundOpenBookPacket). - * Книга НЕ залишається в інвентарі — тимчасово ставиться в руку, - * відкривається, одразу повертається оригінальний предмет. + * Команда /rules. Повністю server-side: клієнт не потребує мода. + * Книга створюється через Data Components API (WrittenBookContent). + * Відкривається через openItemGui — книга НЕ залишається в інвентарі. */ public class RulesCommand { @@ -87,12 +85,23 @@ public class RulesCommand { player.setItemInHand(hand, originalItem); } + /** + * Створює книгу правил: + * 1. Сторінка 0 — Легенда + Зміст + * 2. Кожен розділ починається на новій сторінці + * 3. Пагінація по ~280 символів/сторінку (реальний ліміт книги) + */ private static ItemStack createRuleBook(RulesData.RulesRoot rules) { ItemStack book = new ItemStack(Items.WRITTEN_BOOK); List> pages = new ArrayList<>(); + + // Сторінка 0: Легенда + Зміст + pages.add(Filterable.passThrough(buildLegendAndContentsPage(rules))); + + // Сторінки з правилами (кожен розділ починається на новій сторінці) for (RulesData.Section section : rules.sections) { - pages.add(Filterable.passThrough(buildPageComponent(section))); + pages.addAll(buildSectionPages(section)); } WrittenBookContent content = new WrittenBookContent( @@ -107,21 +116,101 @@ public class RulesCommand { return book; } - private static Component buildPageComponent(RulesData.Section section) { - StringBuilder pageText = new StringBuilder(); - pageText.append("§l§n").append(section.title).append("§r\n\n"); + /** Сторінка 0: Легенда + Зміст */ + private static Component buildLegendAndContentsPage(RulesData.RulesRoot rules) { + StringBuilder sb = new StringBuilder(); + sb.append("§l§nПравила серверу§r\n\n"); + sb.append("§l§nЛегенда важливості§r\n"); + sb.append("§c⚠§r — Тяжке порушення (бан 5+ днів або перманент)\n"); + sb.append("§e⚠§r — Середнє порушення (бан 1–5 днів)\n"); + sb.append("§a⚠§r — Легке порушення (попередження/мут)\n\n"); + sb.append("§l§nЗміст§r\n\n"); - if (section.entries != null) { - for (RulesData.Entry entry : section.entries) { - pageText.append(entry.severity.colorCode) - .append("[!] §r") - .append(entry.number) - .append(". ") - .append(entry.text) - .append("\n\n"); - } + int page = 1; + for (RulesData.Section section : rules.sections) { + sb.append(" §e► §r§f").append(section.title).append(" §7(стор. ").append(page).append(")§r\n"); + page += estimatePagesForSection(section); } - return Component.literal(pageText.toString()); + sb.append("\n§7Стрілки в книзі — навігація.§r"); + return Component.literal(sb.toString()); } -} + + /** Оцінює скільки сторінок займе розділ (~180 видимих символів/сторінку — фізичний ліміт книги) */ + private static int estimatePagesForSection(RulesData.Section section) { + if (section.entries == null || section.entries.isEmpty()) return 1; + + final int CHARS_PER_PAGE = 180; + int totalVisible = section.title.length() + 30; + + for (RulesData.Entry entry : section.entries) { + totalVisible += visibleLength(entry.number) + visibleLength(entry.text) + 30; + } + + return Math.max(1, (totalVisible + CHARS_PER_PAGE - 1) / CHARS_PER_PAGE); + } + + /** Повертає довжину рядка БЕЗ §-кодів (тільки видимі символи) */ + private static int visibleLength(String s) { + if (s == null) return 0; + int len = 0; + boolean inCode = false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '§') { + inCode = true; + continue; + } + if (inCode) { + inCode = false; + continue; + } + len++; + } + return len; + } + + /** Будує сторінки для одного розділу з пагінацією */ + private static List> buildSectionPages(RulesData.Section section) { + List> pages = new ArrayList<>(); + + if (section.entries == null || section.entries.isEmpty()) { + pages.add(Filterable.passThrough(Component.literal("§l§n" + section.title + "§r\n\n§7(розділ порожній)§r"))); + return pages; + } + + final int CHARS_PER_PAGE = 180; + StringBuilder currentPage = new StringBuilder(); + int currentVisible = 0; + boolean firstPage = true; + + for (RulesData.Entry entry : section.entries) { + String symbol = entry.severity != null ? entry.severity.colorCode + "⚠§r" : "§a⚠§r"; + String entryText = symbol + " " + entry.number + ". " + entry.text + "\n\n"; + int entryVisible = visibleLength(entryText); + + if (firstPage) { + String header = "§l§n" + section.title + "§r\n\n"; + currentPage.append(header); + currentVisible = visibleLength(header); + firstPage = false; + } + + // Якщо правило не вміщується на поточній сторінці — переносимо ЦІЛЕ правило на нову сторінку + if (currentVisible + entryVisible > CHARS_PER_PAGE && currentVisible > 0) { + pages.add(Filterable.passThrough(Component.literal(currentPage.toString()))); + currentPage = new StringBuilder(); + currentVisible = 0; + } + + currentPage.append(entryText); + currentVisible += entryVisible; + } + + if (currentVisible > 0) { + pages.add(Filterable.passThrough(Component.literal(currentPage.toString()))); + } + + return pages; + } +} \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesData.java b/src/main/java/org/examp/john/rules/RulesData.java index ec72582..f1f30fc 100644 --- a/src/main/java/org/examp/john/rules/RulesData.java +++ b/src/main/java/org/examp/john/rules/RulesData.java @@ -1,5 +1,6 @@ package org.examp.john.rules; +import java.util.ArrayList; import java.util.List; /** @@ -49,8 +50,8 @@ public class RulesData { /** Один пункт правила всередині розділу. */ public static class Entry { - public String number; // напр. "1.1" або "IV" - public String text; // текст правила / опис порушення + public String number; + public String text; public Severity severity; public Entry(String number, String text, Severity severity) { @@ -79,4 +80,78 @@ public class RulesData { this.sections = sections; } } + + /** Повний набір правил за замовчуванням для створення конфігу при першому запуску. */ + public static RulesRoot createDefaultRules() { + List
sections = new ArrayList<>(); + + // 1. ЧЕСНА ГРА + sections.add(new Section("1. ЧЕСНА ГРА", List.of( + new Entry("1.1", "Чити та заборонені модифікації. Заборонено використовувати чити, модифікації, ресурспаки або інші засоби, які дають перевагу.", Severity.HEAVY), + new Entry("1.2", "Використання багів. Заборонено навмисно використовувати помилки та баги сервера для отримання переваги.", Severity.HEAVY), + new Entry("1.3", "Дюпи піску та подібні механіки дозволені. Заборонено дюпи, що ламають економіку або стабільність сервера.", Severity.HEAVY), + new Entry("1.4", "Обман адміністрації. Заборонено навмисно вводити адміністрацію в оману щодо обставин порушення.", Severity.HEAVY), + new Entry("1.5", "Порушення стабільності сервера. Заборонено створювати навантаження, що спричиняють нестабільність.", Severity.HEAVY), + new Entry("1.6", "Реальні гроші. Заборонено купувати/продавати ігрові ресурси за реальні гроші.", Severity.HEAVY), + new Entry("1.7", "Кількість акаунтів. Один гравець — не більше 5 акаунтів.", Severity.HEAVY), + new Entry("1.8", "Продаж або передача акаунта. Заборонено.", Severity.HEAVY), + new Entry("1.9", "Злам акаунтів. Заборонено намагатися отримати доступ до чужих акаунтів.", Severity.HEAVY), + new Entry("1.10", "Підбурювання до порушень. Заборонено закликати інших гравців порушувати правила.", Severity.MEDIUM), + new Entry("1.11", "Шкідлива дезінформація. Заборонено поширювати неправду, що шкодить гравцям/проєкту.", Severity.MEDIUM), + new Entry("1.12", "Персональні дані. Заборонено використовувати/поширювати дані інших без згоди.", Severity.HEAVY), + new Entry("1.13", "Обхід покарання. Заборонено обходити бан/мут через інші акаунти.", Severity.HEAVY), + new Entry("1.14", "Шкідливі дії проти адміністрації. Заборонено навмисно шкодити адмінам/персоналу.", Severity.HEAVY), + new Entry("1.15", "Атаки на сервер та лаунчер. Заборонено втручатися в роботу інфраструктури.", Severity.HEAVY) + ))); + + // 2. СПІЛКУВАННЯ + sections.add(new Section("2. СПІЛКУВАННЯ", List.of( + new Entry("2.1", "Мова спілкування. Основні: українська, англійська. Російська заборонена в публічному чаті.", Severity.MEDIUM), + new Entry("2.2", "Образи, приниження, провокації. Заборонено ображати гравців, їхніх родичів або адміністрацію. Порнографія заборонена.", Severity.MEDIUM), + new Entry("2.3", "Ненормативна лексика. Заборонено надмірне використання.", Severity.LIGHT), + new Entry("2.4", "Caps Lock. Заборонено надмірне використання, якщо заважає спілкуванню.", Severity.LIGHT), + new Entry("2.5", "Реклама. Заборонено рекламувати сторонні проєкти без дозволу адміністрації (у тому числі в ПМ).", Severity.HEAVY), + new Entry("2.6", "Провокації та конфлікти. Заборонено навмисно розпалювати конфлікти.", Severity.HEAVY), + new Entry("2.7", "Публічні звинувачення адміністрації. Заборонено безпідставно дискредитувати модерацію. Критику — через офіційні канали.", Severity.LIGHT) + ))); + + // 3. ТЕРИТОРІЇ ТА БУДІВНИЦТВО + sections.add(new Section("3. ТЕРИТОРІЇ ТА БУДІВНИЦТВО", List.of( + new Entry("3.1", "Територія гравця. Гравець розпоряджається своєю територією. Втручання без дозволу — порушення.", Severity.MEDIUM), + new Entry("3.2", "Гріферство. Заборонено руйнувати будівлі, псувати територію, знищувати майно без дозволу.", Severity.HEAVY), + new Entry("3.3", "Неприйнятні споруди. Заборонено: образливий/провокаційний/порнографічний/політичний/нацистський/наркотичний контент.", Severity.MEDIUM), + new Entry("3.4", "Псування світу. Заборонено створювати безлад: хаотичне знищення ландшафту, залишення занедбаних будівель, розливання рідин, безглузді стовпи.", Severity.MEDIUM), + new Entry("3.5", "Контейнери з чужими ресурсами. Заборонено знищувати скрині/шалкери з чужими речами без причини.", Severity.MEDIUM), + new Entry("3.6", "Назви предметів. Не повинні містити заборонений контент.", Severity.LIGHT) + ))); + + // 4. НІКНЕЙМИ, СКІНИ ТА ПРОФІЛІ + sections.add(new Section("4. НІКНЕЙМИ, СКІНИ ТА ПРОФІЛІ", List.of( + new Entry("4.1", "Заборонені нікнейми: образи, провокації, порнографія, реклама, наркотики, неприйнятний контент.", Severity.HEAVY), + new Entry("4.2", "Заборонені префікси: образливий, провокаційний, порнографічний, рекламний контент.", Severity.MEDIUM), + new Entry("4.3", "Скіни та плащі. Заборонено порнографічний/неприйнятний контент.", Severity.MEDIUM), + new Entry("4.4", "Імітація адміністрації. Заборонено виглядати як адмін/команда проєкту або відомі медіа-особи.", Severity.HEAVY), + new Entry("4.5", "Імітація іншого гравця. Заборонено створювати схожі ніки з метою обману.", Severity.HEAVY), + new Entry("4.6", "Вхід під чужим акаунтом. Заборонено.", Severity.HEAVY) + ))); + + // 5. PvP ТА ВЗАЄМОДІЯ + sections.add(new Section("5. PvP ТА ВЗАЄМОДІЯ", List.of( + new Entry("5.1", "Перебування на чужій території. Піти після другого прохання власника. Виняток — обов'язки персоналу.", Severity.LIGHT) + ))); + + // 6. ТЕХНІЧНІ ТА ІНШІ ПОРУШЕННЯ + sections.add(new Section("6. ТЕХНІЧНІ ТА ІНШІ ПОРУШЕННЯ", List.of( + new Entry("6.1", "Втручання в роботу проєкту. Заборонено втручатися в сервер/лаунчер для переваги/обходу захисту.", Severity.HEAVY), + new Entry("6.2", "Обхід систем захисту. Заборонено обходити авторизацію/захист через сторонні клієнти.", Severity.HEAVY), + new Entry("6.3", "Порушення, не передбачені правилами. Адміністрація може тимчасово обмежити доступ для з'ясування.", Severity.MEDIUM) + ))); + + // 7. ІВЕНТИ + sections.add(new Section("7. ІВЕНТИ", List.of( + new Entry("7.1", "Правила івентів. Необхідно дотримуватися окремих правил івенту. Порушення карається за умовами івенту.", Severity.LIGHT) + ))); + + return new RulesRoot(sections); + } } \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesLoader.java b/src/main/java/org/examp/john/rules/RulesLoader.java index 7c0fb06..2882984 100644 --- a/src/main/java/org/examp/john/rules/RulesLoader.java +++ b/src/main/java/org/examp/john/rules/RulesLoader.java @@ -65,14 +65,12 @@ public class RulesLoader { 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)); + RulesData.RulesRoot defaultRoot = RulesData.createDefaultRules(); try (var writer = Files.newBufferedWriter(CONFIG_PATH, StandardCharsets.UTF_8)) { new GsonBuilder().setPrettyPrinting().create().toJson(defaultRoot, writer); } - Localchat.magicModLog.info("Створено конфіг за замовчуванням: {}", CONFIG_PATH.toAbsolutePath()); + Localchat.magicModLog.info("Створено конфіг правил за замовчуванням: {}", CONFIG_PATH.toAbsolutePath()); } } \ No newline at end of file