Получение слова из чата при клике

Версия Minecraft
1.12.2
API
Forge
6
1
2
Нужна помощь: получение конкретного слова из чата, на которое кликнул человек.

Идея (в paint схематично нарисовал ожидаемое поведение): человек просто открывает чат и кликает на какое-то слово из сообщения в чате. Это слово должно затем неким образом обрабатываться (обработка не является частью вопроса, до нее еще дойти надо).

желаемое_поведение_схема_paint.jpg


Вопрос: каким образом возможно вытащить это конкретное слово, на которое кликнул человек?

Мои действия: я создал обработчик GuiScreenEvent.MouseInputEvent события, после чего я получил GuiNewChat и вытащил строку по координатам, которые я узнал за счет Mouse.getX/Y(). Это работает, но проблема в том, что я получил всю строку целиком, а не конкретное слово, на которое нажал человек. Что следует сделать дальше, чтобы вытащить именно слово, на которое кликнул игрок?

Я думал попробовать расплитать строку и как то ее обработать или использовать все те же полученные от Mouse координаты, чтобы что то со строкой сделать, но, во первых, не думаю, что это будет работать, во вторых, похоже на какой то костыль ведь.

Код:

Обработчик нажатия на слово в чате:
package com.s0qva.easypunishment.client.handler;

import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiNewChat;
import net.minecraft.util.text.ITextComponent;
import net.minecraft.util.text.TextComponentString;
import net.minecraftforge.client.event.GuiScreenEvent;
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.lwjgl.input.Mouse;

import java.util.Objects;

public class SelectedChatWordEventHandler {
    private static final Logger LOGGER = LogManager.getLogger();
    private static final int LEFT_MOUSE_BUTTON_INDEX = 0;

    @SubscribeEvent
    public void extractSelectedChatWord(GuiScreenEvent.MouseInputEvent event) {
        ITextComponent selectedMessageLine;
        ITextComponent messageToSend;
        String selectedMessage;
        GuiNewChat chatGUI = getGuiNewChat();
        boolean isLeftMouseButton = Mouse.isButtonDown(LEFT_MOUSE_BUTTON_INDEX);
        int xSelectedCord = Mouse.getX();
        int ySelectedCord = Mouse.getY();

        if (!isLeftMouseButton || !chatGUI.getChatOpen()) {
            return;
        }
        LOGGER.info("X: {}, Y: {}", xSelectedCord, ySelectedCord);
        selectedMessageLine = chatGUI.getChatComponent(xSelectedCord, ySelectedCord);

        if (Objects.isNull(selectedMessageLine)) {
            LOGGER.warn("Failed to obtain a chat message");
            return;
        }
        selectedMessage = selectedMessageLine.getUnformattedText();
        LOGGER.info("Obtained message: {}", selectedMessage);
        messageToSend = new TextComponentString("You've selected: " + selectedMessage);
        LOGGER.info("Message to send: {}", messageToSend);
        sendMessage(messageToSend);
    }

    private GuiNewChat getGuiNewChat() {
        return Minecraft.getMinecraft()
                .ingameGUI
                .getChatGUI();
    }

    private void sendMessage(ITextComponent message) {
        Minecraft.getMinecraft()
                .player
                .sendMessage(message);
    }
}

P.S. еще вопрос: почему в чат отправляется два (или даже больше) сообщений, когда я нажимаю на сообщение в чате?

чат.jpg
логи.jpg
 
Решение
Я покопался в исходном коде класса GuiNewChat, чтобы понять, как происходит получение сообщения из чата по координатам мыши, после чего решил переписать этот механизм таким образом, чтобы получать не всю строку целиком, а только слово, на которое человек кликнул. Как я и упомянал ранее, у меня была идея использовать полученные координаты мыши для извлечения нужного слова из всей строки и я, собственно, это реализовал, хоть я и тестировал это как в сингл плеере, так и на серверах, и работает все корректно, но я по прежнему считаю, что это больше костыль, нежели нормально реализованный механизм, поскольку я считаю, что существуют краевые задачи, на которых извелечение слова из чата, на которое нажал человек, будет иметь в лучшем случае...
1,560
86
204
P.S. еще вопрос: почему в чат отправляется два (или даже больше) сообщений, когда я нажимаю на сообщение в чате?
Сделай задержку несколько милисекунд. А то этот ивент срабатывает и на то сообщение, которое появляется.
 
6
1
2
Я покопался в исходном коде класса GuiNewChat, чтобы понять, как происходит получение сообщения из чата по координатам мыши, после чего решил переписать этот механизм таким образом, чтобы получать не всю строку целиком, а только слово, на которое человек кликнул. Как я и упомянал ранее, у меня была идея использовать полученные координаты мыши для извлечения нужного слова из всей строки и я, собственно, это реализовал, хоть я и тестировал это как в сингл плеере, так и на серверах, и работает все корректно, но я по прежнему считаю, что это больше костыль, нежели нормально реализованный механизм, поскольку я считаю, что существуют краевые задачи, на которых извелечение слова из чата, на которое нажал человек, будет иметь в лучшем случае неправильный результат, а в худшем - неоднозначное поведение (речь идет именно о том механизме, которое реализовал я лично, для выполнения поставленной задачи).

Код, содержащий ключевую логику для выполнения поставленной задачи (в спойлерах прикреплю код других классов, вдруг кому понадобится):

SelectedChatWordEventHandler:
package com.s0qva.easypunishment.client.handler;

import com.s0qva.easypunishment.client.enumeration.MouseButtonIndex;
import com.s0qva.easypunishment.client.timer.SelectedChatWordEventTimer;
import com.s0qva.easypunishment.client.util.ChatMessageUtil;
import com.s0qva.easypunishment.client.util.MinecraftUtil;
import com.s0qva.easypunishment.client.util.StringUtil;
import net.minecraft.client.gui.GuiChat;
import net.minecraft.client.gui.GuiNewChat;
import net.minecraft.util.text.ITextComponent;
import net.minecraft.util.text.TextComponentString;
import net.minecraftforge.client.event.GuiScreenEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.lwjgl.input.Mouse;

@Mod.EventBusSubscriber
public class SelectedChatWordEventHandler {
    private static final Logger LOGGER = LogManager.getLogger(SelectedChatWordEventHandler.class);
    private static final SelectedChatWordEventTimer TIMER = new SelectedChatWordEventTimer();

    @SubscribeEvent
    public static void extractSelectedChatWord(GuiScreenEvent.MouseInputEvent event) {
        if (!(event.getGui() instanceof GuiChat)) {
            return;
        }
        GuiNewChat chatGUI = MinecraftUtil.obtainGuiNewChat();
        boolean isLeftMouseButton = Mouse.isButtonDown(MouseButtonIndex.LEFT_MOUSE_BUTTON.getIndex());
        int xSelectedCord = Mouse.getX();
        int ySelectedCord = Mouse.getY();

        if (!isLeftMouseButton || !chatGUI.getChatOpen() || !TIMER.canPerform()) {
            return;
        }
        String selectedWord = ChatMessageUtil.obtainSelectedChatWord(chatGUI, xSelectedCord, ySelectedCord);

        if (StringUtil.isBlank(selectedWord)) {
            LOGGER.warn("Selected word is blank");
            return;
        }
        ITextComponent messageToSend = new TextComponentString("You've selected: " + selectedWord);
        MinecraftUtil.sendMessage(messageToSend);
        TIMER.restrictRepeats();
    }
}

ChatMessageUtil:
package com.s0qva.easypunishment.client.util;
package com.s0qva.easypunishment.client.util;

import net.minecraft.client.gui.ChatLine;
import net.minecraft.client.gui.FontRenderer;
import net.minecraft.client.gui.GuiNewChat;
import net.minecraft.client.gui.ScaledResolution;
import net.minecraft.util.math.MathHelper;
import net.minecraft.util.text.ITextComponent;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.util.List;
import java.util.Objects;

public final class ChatMessageUtil {
    public static final String FORMATTED_CHAT_MESSAGE_REGEX = "§[0-9a-zA-Z]";
    private static final Logger LOGGER = LogManager.getLogger(ChatMessageUtil.class);

    private ChatMessageUtil() {
    }

    public static String obtainUnformattedMessageText(ITextComponent message) {
        if (isNull(message)) {
            LOGGER.warn("Received ITextComponent message is null");
            return StringUtil.EMPTY;
        }
        return obtainUnformattedMessageText(message.getUnformattedText());
    }

    public static String obtainUnformattedMessageText(String text) {
        if (StringUtil.isBlank(text)) {
            LOGGER.warn("Received message text is blank");
            return StringUtil.EMPTY;
        }
        return text.replaceAll(FORMATTED_CHAT_MESSAGE_REGEX, StringUtil.EMPTY);
    }

    public static String obtainSelectedChatWord(GuiNewChat chat, int xSelectedCord, int ySelectedCord) {
        if (!chat.getChatOpen()) {
            return StringUtil.EMPTY;
        }
        List<ChatLine> drawnChatLines = ReflectionUtil.obtainList(chat, ChatLine.class, "drawnChatLines");
        Integer scrollPosition = ReflectionUtil.obtainInteger(chat, "scrollPos");

        if (Objects.isNull(drawnChatLines) || Objects.isNull(scrollPosition)) {
            return StringUtil.EMPTY;
        }
        ScaledResolution resolution = new ScaledResolution(MinecraftUtil.MINECRAFT);
        FontRenderer fontRenderer = MinecraftUtil.obtainFontRender();
        float chatScale = chat.getChatScale();
        int scaleFactor = resolution.getScaleFactor();
        int selectedMessageWidth = MathHelper.floor((float) (xSelectedCord / scaleFactor - 2) / chatScale);
        int selectedMessageHeight = MathHelper.floor((float) (ySelectedCord / scaleFactor - 40) / chatScale);

        if (selectedMessageWidth < 0 || selectedMessageHeight < 0) {
            return StringUtil.EMPTY;
        }
        int minimalHeight = Math.min(chat.getLineCount(), drawnChatLines.size());

        if (selectedMessageWidth > MathHelper.floor((float) chat.getChatWidth() / chat.getChatScale())
                || selectedMessageHeight >= fontRenderer.FONT_HEIGHT * minimalHeight + minimalHeight) {
            return StringUtil.EMPTY;
        }
        int selectedMessageLinePosition = selectedMessageHeight / fontRenderer.FONT_HEIGHT + scrollPosition;

        if (selectedMessageLinePosition < 0 || selectedMessageLinePosition >= drawnChatLines.size()) {
            return StringUtil.EMPTY;
        }
        ChatLine chatline = drawnChatLines.get(selectedMessageLinePosition);
        ITextComponent chatMessage = chatline.getChatComponent();

        return obtainCertainChatWord(chatMessage, selectedMessageWidth);
    }

    public static String obtainCertainChatWord(ITextComponent message, int maxMessageWidth) {
        String unformattedMessageText = obtainUnformattedMessageText(message);
        return obtainCertainChatWord(unformattedMessageText, maxMessageWidth);
    }

    public static String obtainCertainChatWord(String text, int maxMessageWidth) {
        if (StringUtil.isBlank(text) || maxMessageWidth < 0) {
            LOGGER.warn("Received message text is blank or maxMessageWidth is negative number");
            return StringUtil.EMPTY;
        }
        FontRenderer fontRenderer = MinecraftUtil.obtainFontRender();
        char[] textChars = text.toCharArray();
        int desiredCharIndex = 0;
        int characterWidthSum = 0;

        for (char currentChar : textChars) {
            desiredCharIndex++;
            characterWidthSum += fontRenderer.getCharWidth(currentChar);

            if (characterWidthSum >= maxMessageWidth) {
                desiredCharIndex--;
                break;
            }
        }
        int rightSpaceCharacterIndex = text.indexOf(CharacterUtil.SPACE_CHARACTER, desiredCharIndex);
        int leftSpaceCharacterIndex = desiredCharIndex;

        try {
            while (leftSpaceCharacterIndex >= 0 && text.charAt(leftSpaceCharacterIndex) != CharacterUtil.SPACE_CHARACTER) {
                leftSpaceCharacterIndex--;
            }
            desiredCharIndex = leftSpaceCharacterIndex + 1;

            return StringUtil.substring(text, desiredCharIndex, rightSpaceCharacterIndex);
        } catch (StringIndexOutOfBoundsException exception) {
            LOGGER.error("An exception occurred: {}", exception.getMessage());
            return StringUtil.EMPTY;
        }
    }

    public static boolean isNull(ITextComponent message) {
        return Objects.isNull(message);
    }
}

P.S. как я уже сказал ранее, я изучал исходный код объекта GuiNewChat, а именно метода getChatComponent(int, int). А в этом методе используются переменные, которые имеют, мягко говоря, специфические имена, соответственно мне пришлось самостоятельно изучать за что каждая переменная отвечает, поэтому говорю сразу, что, возможно, некоторые переменные в методе obtainSelectedWord(GuiNewChat, int, int) могут иметь неправильное (исходя из того, для чего переменная применяется) название. Если вы знаете за что какая переменная отвечает и мое название не соответствует действительности, то буду рад, если сообщите об этом.

Классы, которые не содержат ключевой логики, но могут показаться интересными:

ReflectionUtil :
package com.s0qva.easypunishment.client.util;

import com.s0qva.easypunishment.client.exception.ClassMismatchException;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import javax.annotation.Nullable;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class ReflectionUtil {
    private static final Logger LOGGER = LogManager.getLogger(ReflectionUtil.class);

    private ReflectionUtil() {
    }

    public static <T, FT> Object obtainObject(T object, Class<FT> desiredFieldTypeClass, String fieldName) {
        try {
            Field foundField = object.getClass().getDeclaredField(fieldName);

            isMatchOrElseThrow(foundField, desiredFieldTypeClass);
            allowAccess(foundField);
            return foundField.get(object);
        } catch (NoSuchFieldException
                 | IllegalAccessException
                 | IllegalArgumentException
                 | NullPointerException exception) {
            LOGGER.error("An exception occurred: {}", exception.getMessage());
            return null;
        }
    }

    public static <T, ET> List<ET> obtainList(T object, Class<ET> listElementTypeClass, String fieldName) {
        Object obtainedObject = obtainObject(object, List.class, fieldName);

        if (Objects.isNull(obtainedObject)) {
            return null;
        }
        List<?> foundList = (List<?>) obtainedObject;
        List<ET> desiredList = new ArrayList<>();

        try {
            for (Object foundListElement : foundList) {
                desiredList.add(listElementTypeClass.cast(foundListElement));
            }
            return desiredList;
        } catch (ClassCastException exception) {
            LOGGER.error("An exception occurred: {}", exception.getMessage());
            return null;
        }
    }

    public static <T> Integer obtainInteger(T object, String fieldName) {
        Object obtainedObject = obtainObject(object, int.class, fieldName);

        if (Objects.isNull(obtainedObject)) {
            return null;
        }
        try {
            return (Integer) obtainedObject;
        } catch (ClassCastException exception) {
            LOGGER.error("An exception occurred: {}", exception.getMessage());
            return null;
        }
    }

    public static void isMatchOrElseThrow(Class<?> firstClass, Class<?> secondClass, @Nullable String exceptionMessage) {
        if (isMatch(firstClass, secondClass)) {
            return;
        }
        if (StringUtils.isNotBlank(exceptionMessage)) {
            throw new ClassMismatchException(exceptionMessage);
        }
        throw new ClassMismatchException();
    }

    public static void isMatchOrElseThrow(Class<?> firstClass, Class<?> secondClass) {
        isMatchOrElseThrow(firstClass, secondClass, StringUtils.EMPTY);
    }

    public static void isMatchOrElseThrow(Field field, Class<?> classToMatch, String exceptionMessage) {
        isMatchOrElseThrow(field.getType(), classToMatch, exceptionMessage);
    }

    public static void isMatchOrElseThrow(Field field, Class<?> classToMatch) {
        isMatchOrElseThrow(field, classToMatch, StringUtils.EMPTY);
    }

    public static boolean isMatch(Class<?> firstClass, Class<?> secondClass) {
        return firstClass.equals(secondClass);
    }

    public static boolean isMatch(Field field, Class<?> classToMatch) {
        return isMatch(field.getType(), classToMatch);
    }

    public static void allowAccess(Field field) {
        field.setAccessible(true);
    }

    public static void restrictAccess(Field field) {
        field.setAccessible(false);
    }
}

SelectedChatWordEventTimer:
package com.s0qva.easypunishment.client.timer;

import java.util.Timer;
import java.util.TimerTask;

public class SelectedChatWordEventTimer {
    public static final long DEFAULT_DELAY = 100L;
    private static final Timer TIMER = new Timer();
    private boolean canPerform = true;

    public void restrictRepeats(long delay) {
        restrictPerforming();
        TIMER.schedule(new TimerTask() {
            @Override
            public void run() {
                allowPerforming();
            }
        }, DEFAULT_DELAY);
    }

    public void restrictRepeats() {
        restrictRepeats(DEFAULT_DELAY);
    }

    private void restrictPerforming() {
        canPerform = false;
    }

    private void allowPerforming() {
        canPerform = true;
    }

    public boolean canPerform() {
        return canPerform;
    }
}

Если у вас есть какие то замечания / идеи / предложения по поводу мной сказанного, то, пожалуйста, сообщите, готов к критике и обсуждениям.
 
Последнее редактирование:
6
1
2
Сделай задержку несколько милисекунд. А то этот ивент срабатывает и на то сообщение, которое появляется.
Как я понял проблема в том, что игнорируется проверка на нажатие мыши, в том плане, что майнкрафт считает, что левая кнопка мыши нажата, хотя по факту - нет. Возможно, это как то связано со скоростью вызова этого ивента, т.е. за 1 клик может сгенерироваться несколько событий и для них проверка на клик ЛКМ пройдет без проблем. Сильно разбираться в этом я не стал, просто создал таймер, который запрещает доступ к обработке ивентов после выполнения 1 обработанного ивента, и который затем через 100 мс разрешает обработку вновь.
 
Сверху