rewrite: /rules via temporary written_book instead of GUI
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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<RulesData.Entry> 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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
|
||||
@@ -13,9 +12,6 @@ public class Localchat implements ModInitializer {
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
ChatHandler.register();
|
||||
|
||||
// Реєстрація мережі /rules (спільна частина: обов'язково і на сервері, і на клієнті)
|
||||
RulesNetworking.registerCommon();
|
||||
RulesCommand.register();
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,36 @@ 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.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.Filterable;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.item.component.WrittenBookContent;
|
||||
import org.examp.john.Localchat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Реєструє команду /rules. Гравець виконує команду -> сервер читає rules.json
|
||||
* -> надсилає RulesPayload через мережу -> клієнт відкриває RulesScreen (кастомний GUI з вкладками).
|
||||
* Реєструє команду /rules. Повністю server-side: не потребує жодного мода
|
||||
* на клієнті гравця. Книга створюється програмно через Data Components API
|
||||
* і відкривається через openItemGui (ClientboundOpenBookPacket).
|
||||
* Книга НЕ залишається в інвентарі — тимчасово ставиться в руку,
|
||||
* відкривається, одразу повертається оригінальний предмет.
|
||||
*/
|
||||
public class RulesCommand {
|
||||
|
||||
private static final Map<UUID, Long> cooldowns = new HashMap<>();
|
||||
private static final long COOLDOWN_MS = 5000;
|
||||
|
||||
public static void register() {
|
||||
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) ->
|
||||
dispatcher.register(buildCommand())
|
||||
@@ -33,11 +51,77 @@ public class RulesCommand {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
Long lastUsed = cooldowns.get(player.getUUID());
|
||||
if (lastUsed != null && (now - lastUsed) < COOLDOWN_MS) {
|
||||
long remainingMs = COOLDOWN_MS - (now - lastUsed);
|
||||
long secondsLeft = (remainingMs + 999) / 1000;
|
||||
player.sendSystemMessage(Component.literal(
|
||||
"Зачекайте ще " + secondsLeft + " сек. перед повторним використанням /rules."
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
cooldowns.put(player.getUUID(), now);
|
||||
|
||||
RulesData.RulesRoot rules = RulesLoader.get();
|
||||
RulesPayload payload = RulesPayload.fromData(rules);
|
||||
|
||||
ServerPlayNetworking.send(player, payload);
|
||||
if (rules.sections == null || rules.sections.isEmpty()) {
|
||||
source.sendFailure(Component.literal("Правила ще не налаштовані. Зверніться до адміністрації."));
|
||||
return 0;
|
||||
}
|
||||
|
||||
openBook(player, rules);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void openBook(ServerPlayer player, RulesData.RulesRoot rules) {
|
||||
InteractionHand hand = InteractionHand.MAIN_HAND;
|
||||
ItemStack originalItem = player.getItemInHand(hand).copy();
|
||||
|
||||
ItemStack book = createRuleBook(rules);
|
||||
Localchat.magicModLog.info("Відкриваємо книгу правил для {}", player.getName().getString());
|
||||
|
||||
player.setItemInHand(hand, book);
|
||||
player.containerMenu.sendAllDataToRemote();
|
||||
player.openItemGui(book, hand);
|
||||
player.setItemInHand(hand, originalItem);
|
||||
}
|
||||
|
||||
private static ItemStack createRuleBook(RulesData.RulesRoot rules) {
|
||||
ItemStack book = new ItemStack(Items.WRITTEN_BOOK);
|
||||
|
||||
List<Filterable<Component>> pages = new ArrayList<>();
|
||||
for (RulesData.Section section : rules.sections) {
|
||||
pages.add(Filterable.passThrough(buildPageComponent(section)));
|
||||
}
|
||||
|
||||
WrittenBookContent content = new WrittenBookContent(
|
||||
Filterable.passThrough("Правила серверу"),
|
||||
"Server",
|
||||
0,
|
||||
pages,
|
||||
true
|
||||
);
|
||||
book.set(DataComponents.WRITTEN_BOOK_CONTENT, content);
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
private static Component buildPageComponent(RulesData.Section section) {
|
||||
StringBuilder pageText = new StringBuilder();
|
||||
pageText.append("§l§n").append(section.title).append("§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");
|
||||
}
|
||||
}
|
||||
|
||||
return Component.literal(pageText.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,20 +19,22 @@ import java.util.List;
|
||||
public class RulesData {
|
||||
|
||||
/**
|
||||
* Рівень тяжкості порушення. Визначає колір трикутника-значка в GUI.
|
||||
* LIGHT -> зелений
|
||||
* MEDIUM -> жовтий
|
||||
* HEAVY -> червоний
|
||||
* Рівень тяжкості порушення. Визначає колір символу-значка в книзі правил
|
||||
* через ванільні коди форматування (§) — книга рендериться повністю клієнтом,
|
||||
* без жодного мода на клієнті.
|
||||
* LIGHT -> зелений (§a)
|
||||
* MEDIUM -> жовтий (§e)
|
||||
* HEAVY -> червоний (§c)
|
||||
*/
|
||||
public enum Severity {
|
||||
LIGHT(0x55FF55), // зелений
|
||||
MEDIUM(0xFFFF55), // жовтий
|
||||
HEAVY(0xFF5555); // червоний
|
||||
LIGHT("§a"),
|
||||
MEDIUM("§e"),
|
||||
HEAVY("§c");
|
||||
|
||||
public final int color;
|
||||
public final String colorCode;
|
||||
|
||||
Severity(int color) {
|
||||
this.color = color;
|
||||
Severity(String colorCode) {
|
||||
this.colorCode = colorCode;
|
||||
}
|
||||
|
||||
public static Severity fromString(String raw) {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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<RulesPayload> TYPE =
|
||||
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(Localchat.MOD_ID, "rules_payload"));
|
||||
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, RulesPayload> CODEC =
|
||||
StreamCodec.of(
|
||||
(buf, payload) -> buf.writeUtf(payload.rulesJson(), Short.MAX_VALUE),
|
||||
buf -> new RulesPayload(buf.readUtf(Short.MAX_VALUE))
|
||||
);
|
||||
|
||||
@Override
|
||||
public Type<? extends CustomPacketPayload> 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);
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,6 @@
|
||||
"fabric-datagen": [
|
||||
"org.examp.john.client.LocalchatDataGenerator"
|
||||
],
|
||||
"client": [
|
||||
"org.examp.john.client.LocalchatClient"
|
||||
],
|
||||
"main": [
|
||||
"org.examp.john.Localchat"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user