feat: Add Paper Chat API with hover/click/mentions/emojis/URLs + custom nicknames

This commit is contained in:
loki5512344 2026-02-23 15:40:48 +01:00
parent 7658f6f3e6
commit 69cae99476
21 changed files with 670 additions and 229 deletions

View file

@ -2,6 +2,7 @@ package com.loki.lochat;
import com.loki.lochat.api.service.IgnoreService;
import com.loki.lochat.api.service.MuteService;
import com.loki.lochat.api.service.NickService;
import com.loki.lochat.commands.*;
import com.loki.lochat.config.ConfigManager;
import com.loki.lochat.config.MessageConfig;
@ -33,7 +34,7 @@ public final class LoChat extends JavaPlugin {
@Override
public void onEnable() {
instance = this;
getLogger().info("Загрузка LoChat v" + getDescription().getVersion() + " для Paper/Folia " + Bukkit.getMinecraftVersion() + "...");
getLogger().info("Загрузка LoChat v" + getPluginMeta().getVersion() + " для Paper/Folia " + Bukkit.getMinecraftVersion() + "...");
// Конфиги
configManager = new ConfigManager(this);
@ -71,6 +72,9 @@ public final class LoChat extends JavaPlugin {
MuteService muteService = serviceRegistry.get(MuteService.class);
if (muteService != null) muteService.save();
NickService nickService = serviceRegistry.get(NickService.class);
if (nickService != null) nickService.save();
if (gradientModule != null) gradientModule.shutdown();
getLogger().info("LoChat отключен!");
}
@ -103,6 +107,10 @@ public final class LoChat extends JavaPlugin {
getCommand("chatspy").setExecutor(new ChatSpyCommand(this));
getCommand("clearchat").setExecutor(new ClearChatCommand(this));
NickCommand nickCmd = new NickCommand(this);
getCommand("nick").setExecutor(nickCmd);
getCommand("nick").setTabCompleter(nickCmd);
LoChatCommand loChatCmd = new LoChatCommand(this);
getCommand("lochat").setExecutor(loChatCmd);
getCommand("lochat").setTabCompleter(loChatCmd);

View file

@ -0,0 +1,58 @@
package com.loki.lochat.api.service;
import org.bukkit.entity.Player;
import java.util.Optional;
import java.util.UUID;
/**
* Сервис для управления кастомными никами
*/
public interface NickService {
/**
* Устанавливает кастомный ник игроку
* @param player UUID игрока
* @param nickname Новый ник (с поддержкой цветов и русских символов)
* @return true если успешно, false если ник занят или невалиден
*/
boolean setNickname(UUID player, String nickname);
/**
* Сбрасывает кастомный ник игрока
* @param player UUID игрока
*/
void resetNickname(UUID player);
/**
* Получает кастомный ник игрока
* @param player UUID игрока
* @return Optional с ником или empty если нет кастомного ника
*/
Optional<String> getNickname(UUID player);
/**
* Проверяет занят ли ник
* @param nickname Ник для проверки
* @return true если ник уже используется
*/
boolean isNicknameTaken(String nickname);
/**
* Валидирует ник (длина, символы)
* @param nickname Ник для проверки
* @return true если ник валиден
*/
boolean isValidNickname(String nickname);
/**
* Обновляет displayName и tabName игрока
* @param player Игрок
*/
void updatePlayerDisplay(Player player);
/**
* Сохраняет данные
*/
void save();
}

View file

@ -30,7 +30,7 @@ public class LoChatCommand implements CommandExecutor, TabCompleter {
}
if (args.length == 0) {
sender.sendMessage(MM.deserialize("&#FFD700LoChat v" + plugin.getDescription().getVersion()));
sender.sendMessage(MM.deserialize("&#FFD700LoChat v" + plugin.getPluginMeta().getVersion()));
sender.sendMessage(MM.deserialize("&#808080Команды:"));
sender.sendMessage(MM.deserialize("&#FFFF00/lochat reload - Перезагрузить конфиги"));
sender.sendMessage(MM.deserialize("&#FFFF00/lochat commands - Список кастомных команд"));

View file

@ -0,0 +1,135 @@
package com.loki.lochat.commands;
import com.loki.lochat.LoChat;
import com.loki.lochat.api.service.NickService;
import com.loki.lochat.utils.ChatFormatter;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
/**
* Команда /nick для установки кастомного ника
*/
public class NickCommand implements CommandExecutor, TabCompleter {
private final LoChat plugin;
private final NickService nickService;
public NickCommand(LoChat plugin) {
this.plugin = plugin;
this.nickService = plugin.getServiceRegistry().get(NickService.class);
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("Эта команда только для игроков!", NamedTextColor.RED));
return true;
}
if (!player.hasPermission("lochat.nick")) {
player.sendMessage(Component.text("У вас нет прав на использование этой команды!", NamedTextColor.RED));
return true;
}
// /nick - показать текущий ник
if (args.length == 0) {
return showCurrentNick(player);
}
// /nick reset - сбросить ник
if (args[0].equalsIgnoreCase("reset")) {
return resetNick(player);
}
// /nick <ник> - установить ник
String nickname = String.join(" ", args);
return setNick(player, nickname);
}
private boolean showCurrentNick(Player player) {
var currentNick = nickService.getNickname(player.getUniqueId());
if (currentNick.isPresent()) {
Component nickComponent = ChatFormatter.parse(currentNick.get());
player.sendMessage(Component.text("Ваш текущий ник: ", NamedTextColor.GRAY)
.append(nickComponent));
player.sendMessage(Component.text("Используйте ", NamedTextColor.GRAY)
.append(Component.text("/nick reset", NamedTextColor.YELLOW))
.append(Component.text(" для сброса", NamedTextColor.GRAY)));
} else {
player.sendMessage(Component.text("У вас нет кастомного ника", NamedTextColor.GRAY));
player.sendMessage(Component.text("Используйте ", NamedTextColor.GRAY)
.append(Component.text("/nick <ник>", NamedTextColor.YELLOW))
.append(Component.text(" для установки", NamedTextColor.GRAY)));
}
return true;
}
private boolean resetNick(Player player) {
var currentNick = nickService.getNickname(player.getUniqueId());
if (currentNick.isEmpty()) {
player.sendMessage(Component.text("У вас нет кастомного ника!", NamedTextColor.RED));
return true;
}
nickService.resetNickname(player.getUniqueId());
player.sendMessage(Component.text("Ваш ник сброшен!", NamedTextColor.GREEN));
return true;
}
private boolean setNick(Player player, String nickname) {
// Валидация
if (!nickService.isValidNickname(nickname)) {
player.sendMessage(Component.text("Невалидный ник!", NamedTextColor.RED));
player.sendMessage(Component.text("Требования:", NamedTextColor.GRAY));
player.sendMessage(Component.text("• Длина: 3-16 символов", NamedTextColor.GRAY));
player.sendMessage(Component.text("• Разрешены: буквы, цифры, _, русские буквы", NamedTextColor.GRAY));
player.sendMessage(Component.text("• Поддержка цветов: <red>текст</red>, <gradient:#FF0000:#00FF00>текст</gradient>", NamedTextColor.GRAY));
return true;
}
// Проверка уникальности
if (nickService.isNicknameTaken(nickname)) {
var currentNick = nickService.getNickname(player.getUniqueId());
// Если это не свой текущий ник
if (currentNick.isEmpty() || !currentNick.get().equals(nickname)) {
player.sendMessage(Component.text("Этот ник уже занят!", NamedTextColor.RED));
return true;
}
}
// Устанавливаем ник
boolean success = nickService.setNickname(player.getUniqueId(), nickname);
if (success) {
Component nickComponent = ChatFormatter.parse(nickname);
player.sendMessage(Component.text("Ваш ник изменён на: ", NamedTextColor.GREEN)
.append(nickComponent));
} else {
player.sendMessage(Component.text("Не удалось установить ник!", NamedTextColor.RED));
}
return true;
}
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
List<String> completions = new ArrayList<>();
if (args.length == 1) {
completions.add("reset");
completions.add("<ник>");
}
return completions;
}
}

View file

@ -37,7 +37,7 @@ public class PlayerInfoCommand implements CommandExecutor {
// Показываем информацию об игроке
sender.sendMessage(ChatFormatter.parse("§6=== Информация об игроке ==="));
sender.sendMessage(ChatFormatter.parse("§eИмя: §f" + target.getName()));
sender.sendMessage(ChatFormatter.parse("§eОтображаемое имя: §f" + target.getDisplayName()));
sender.sendMessage(ChatFormatter.parse("§eОтображаемое имя: §f" + target.displayName()));
sender.sendMessage(ChatFormatter.parse("§eУровень: §f" + target.getLevel()));
sender.sendMessage(ChatFormatter.parse("§eЗдоровье: §f" + Math.round(target.getHealth()) + "/20"));
sender.sendMessage(ChatFormatter.parse("§eМир: §f" + target.getWorld().getName()));

View file

@ -48,6 +48,7 @@ public class ServiceRegistry {
register(IgnoreService.class, new IgnoreServiceImpl(plugin));
register(SpyService.class, new SpyServiceImpl(plugin, messageConfig));
register(MentionService.class, new MentionServiceImpl(configManager));
register(NickService.class, new NickServiceImpl(plugin));
}
public <T> void register(Class<T> serviceClass, T implementation) {

View file

@ -34,11 +34,13 @@ public class ChatServiceImpl implements ChatService {
? (Component) message
: ChatFormatter.parse(message.toString());
String formatted = format
.replace("{prefix}", prefix)
.replace("{player}", sender.getName());
// Используем displayName игрока (с градиентом если есть)
Component playerName = sender.displayName();
String formatted = format.replace("{prefix}", prefix);
Component finalComponent = ChatFormatter.parse(formatted)
.replaceText(builder -> builder.matchLiteral("{player}").replacement(playerName))
.replaceText(builder -> builder.matchLiteral("{message}").replacement(messageComponent));
for (Player player : Bukkit.getOnlinePlayers()) {
@ -58,9 +60,11 @@ public class ChatServiceImpl implements ChatService {
? (Component) message
: ChatFormatter.parse(message.toString());
String formatted = format.replace("{player}", sender.getName());
// Используем displayName игрока (с градиентом если есть)
Component playerName = sender.displayName();
Component finalComponent = ChatFormatter.parse(formatted)
Component finalComponent = ChatFormatter.parse(format)
.replaceText(builder -> builder.matchLiteral("{player}").replacement(playerName))
.replaceText(builder -> builder.matchLiteral("{message}").replacement(messageComponent));
for (Player player : Bukkit.getOnlinePlayers()) {

View file

@ -37,22 +37,15 @@ public class MessageServiceImpl implements MessageService {
// Создаем объект сообщения
ChatMessage message = ChatMessage.create(player, rawMessage);
// Применяем фильтры
// Применяем фильтры (мут, кулдаун)
for (MessageFilter filter : filters) {
if (!filter.apply(player, message)) {
return false; // Сообщение заблокировано
}
}
// Отправляем сообщение
Component formattedMessage = ChatFormatter.parse(message.getContent());
if (message.isGlobal()) {
chatService.sendGlobalMessage(player, formattedMessage);
} else {
chatService.sendLocalMessage(player, formattedMessage);
}
// Возвращаем true - сообщение разрешено
// Рендерер сам отправит его
return true;
}
}

View file

@ -0,0 +1,187 @@
package com.loki.lochat.core.service;
import com.loki.lochat.api.service.NickService;
import com.loki.lochat.utils.ChatFormatter;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Реализация сервиса кастомных ников
*/
public class NickServiceImpl implements NickService {
private final JavaPlugin plugin;
private final Map<UUID, String> nicknames = new ConcurrentHashMap<>();
private final File dataFile;
private FileConfiguration data;
// Настройки
private static final int MIN_LENGTH = 3;
private static final int MAX_LENGTH = 16;
public NickServiceImpl(JavaPlugin plugin) {
this.plugin = plugin;
this.dataFile = new File(plugin.getDataFolder(), "nicknames.yml");
load();
}
private void load() {
if (!dataFile.exists()) {
try {
dataFile.getParentFile().mkdirs();
dataFile.createNewFile();
} catch (IOException e) {
plugin.getLogger().warning("Не удалось создать nicknames.yml: " + e.getMessage());
}
}
data = YamlConfiguration.loadConfiguration(dataFile);
// Загружаем ники
if (data.contains("nicknames")) {
for (String key : data.getConfigurationSection("nicknames").getKeys(false)) {
UUID uuid = UUID.fromString(key);
String nickname = data.getString("nicknames." + key);
nicknames.put(uuid, nickname);
}
}
plugin.getLogger().info("Загружено " + nicknames.size() + " кастомных ников");
}
@Override
public void save() {
// Сохраняем ники
data.set("nicknames", null); // Очищаем
for (Map.Entry<UUID, String> entry : nicknames.entrySet()) {
data.set("nicknames." + entry.getKey().toString(), entry.getValue());
}
try {
data.save(dataFile);
} catch (IOException e) {
plugin.getLogger().warning("Не удалось сохранить nicknames.yml: " + e.getMessage());
}
}
@Override
public boolean setNickname(UUID player, String nickname) {
// Валидация
if (!isValidNickname(nickname)) {
return false;
}
// Проверка уникальности
if (isNicknameTaken(nickname)) {
// Проверяем не свой ли это ник
String currentNick = nicknames.get(player);
if (currentNick == null || !stripColors(currentNick).equalsIgnoreCase(stripColors(nickname))) {
return false;
}
}
// Устанавливаем ник
nicknames.put(player, nickname);
// Обновляем display
Player onlinePlayer = Bukkit.getPlayer(player);
if (onlinePlayer != null) {
updatePlayerDisplay(onlinePlayer);
}
return true;
}
@Override
public void resetNickname(UUID player) {
nicknames.remove(player);
// Сбрасываем display
Player onlinePlayer = Bukkit.getPlayer(player);
if (onlinePlayer != null) {
onlinePlayer.displayName(Component.text(onlinePlayer.getName()));
onlinePlayer.playerListName(Component.text(onlinePlayer.getName()));
}
}
@Override
public Optional<String> getNickname(UUID player) {
return Optional.ofNullable(nicknames.get(player));
}
@Override
public boolean isNicknameTaken(String nickname) {
String cleanNick = stripColors(nickname).toLowerCase();
// Проверяем существующие ники
for (String existingNick : nicknames.values()) {
if (stripColors(existingNick).equalsIgnoreCase(cleanNick)) {
return true;
}
}
// Проверяем реальные ники онлайн игроков
for (Player player : Bukkit.getOnlinePlayers()) {
if (player.getName().equalsIgnoreCase(cleanNick)) {
return true;
}
}
return false;
}
@Override
public boolean isValidNickname(String nickname) {
if (nickname == null || nickname.isEmpty()) {
return false;
}
// Убираем цвета для проверки длины
String cleanNick = stripColors(nickname);
// Проверка длины
if (cleanNick.length() < MIN_LENGTH || cleanNick.length() > MAX_LENGTH) {
return false;
}
// Проверка символов (разрешены буквы, цифры, _, русские буквы)
if (!cleanNick.matches("[a-zA-Zа-яА-Я0-9_]+")) {
return false;
}
return true;
}
@Override
public void updatePlayerDisplay(Player player) {
Optional<String> nickname = getNickname(player.getUniqueId());
if (nickname.isPresent()) {
// Парсим ник с цветами
Component nickComponent = ChatFormatter.parse(nickname.get());
player.displayName(nickComponent);
player.playerListName(nickComponent);
} else {
// Используем обычное имя
player.displayName(Component.text(player.getName()));
player.playerListName(Component.text(player.getName()));
}
}
/**
* Убирает цвета из строки
*/
private String stripColors(String text) {
Component component = ChatFormatter.parse(text);
return PlainTextComponentSerializer.plainText().serialize(component);
}
}

View file

@ -188,7 +188,9 @@ public class GradientAdminCommand implements CommandExecutor, TabCompleter {
data.setPrefixEnabled(true);
data.setPrefixPurchased(true);
module.getLuckPermsHook().setPrefix(target.getUniqueId(), DisplayNameUtil.buildColoredPrefix(module, data));
if (module.getLuckPermsHook() != null) {
module.getLuckPermsHook().setPrefix(target.getUniqueId(), DisplayNameUtil.buildColoredPrefix(module, data));
}
Player onlineTarget = target.getPlayer();
if (onlineTarget != null) {
@ -244,10 +246,12 @@ public class GradientAdminCommand implements CommandExecutor, TabCompleter {
GradientPlayerData data = module.getDataManager().getPlayerData(target.getUniqueId());
data.setPrefixEnabled(enable);
if (enable && data.hasPrefix()) {
module.getLuckPermsHook().setPrefix(target.getUniqueId(), DisplayNameUtil.buildColoredPrefix(module, data));
} else {
module.getLuckPermsHook().removePrefix(target.getUniqueId());
if (module.getLuckPermsHook() != null) {
if (enable && data.hasPrefix()) {
module.getLuckPermsHook().setPrefix(target.getUniqueId(), DisplayNameUtil.buildColoredPrefix(module, data));
} else {
module.getLuckPermsHook().removePrefix(target.getUniqueId());
}
}
Player onlineTarget = target.getPlayer();
@ -306,7 +310,9 @@ public class GradientAdminCommand implements CommandExecutor, TabCompleter {
data.setPrefix(null);
data.setPrefixEnabled(false);
module.getLuckPermsHook().removePrefix(target.getUniqueId());
if (module.getLuckPermsHook() != null) {
module.getLuckPermsHook().removePrefix(target.getUniqueId());
}
Player onlineTarget = target.getPlayer();
if (onlineTarget != null) {

View file

@ -84,7 +84,9 @@ public class GradientPrefixCommand implements CommandExecutor, TabCompleter {
}
data.setPrefixEnabled(true);
module.getLuckPermsHook().setPrefix(player, DisplayNameUtil.buildColoredPrefix(module, data));
if (module.getLuckPermsHook() != null) {
module.getLuckPermsHook().setPrefix(player, DisplayNameUtil.buildColoredPrefix(module, data));
}
saveAndUpdate(player, data);
msg.send(player, "prefix-enabled");
return true;
@ -93,7 +95,9 @@ public class GradientPrefixCommand implements CommandExecutor, TabCompleter {
private boolean handleOff(Player player, String[] args) {
GradientPlayerData data = module.getDataManager().getPlayerData(player.getUniqueId());
data.setPrefixEnabled(false);
module.getLuckPermsHook().removePrefix(player);
if (module.getLuckPermsHook() != null) {
module.getLuckPermsHook().removePrefix(player);
}
saveAndUpdate(player, data);
module.getMessages().send(player, "prefix-disabled");
return true;
@ -110,7 +114,9 @@ public class GradientPrefixCommand implements CommandExecutor, TabCompleter {
data.setPrefix(null);
data.setPrefixEnabled(false);
module.getLuckPermsHook().removePrefix(player);
if (module.getLuckPermsHook() != null) {
module.getLuckPermsHook().removePrefix(player);
}
saveAndUpdate(player, data);
msg.send(player, "prefix-reset-success");
return true;

View file

@ -129,7 +129,7 @@ public class GradientConfirmGUI implements InventoryHolder {
boolean useLegacyForPreview = true;
// Если нет кастомного префикса - используем LuckPerms префикс
if ((prefixToUse == null || prefixToUse.isEmpty()) && module.getLuckPermsHook().isEnabled()) {
if ((prefixToUse == null || prefixToUse.isEmpty()) && module.getLuckPermsHook() != null && module.getLuckPermsHook().isEnabled()) {
String lpPrefix = module.getLuckPermsHook().getActivePrefix(player);
if (lpPrefix != null && !lpPrefix.isEmpty()) {
// Конвертируем LP префикс в legacy формат

View file

@ -82,7 +82,9 @@ public class GradientGUIListener implements Listener {
data.setColors(gui.getColors());
data.setColorEnabled(true);
}
module.getLuckPermsHook().setPrefix(player, DisplayNameUtil.buildColoredPrefix(module, data));
if (module.getLuckPermsHook() != null) {
module.getLuckPermsHook().setPrefix(player, DisplayNameUtil.buildColoredPrefix(module, data));
}
msg.send(player, price > 0 ? "prefix-success" : "prefix-success-free",
"price", String.valueOf(price));
}

View file

@ -32,7 +32,7 @@ public final class DisplayNameUtil {
if (data.isPrefixEnabled() && data.hasPrefix()) {
// Есть кастомный префикс
prefix = data.getPrefix();
} else if (module.getLuckPermsHook().isEnabled()) {
} else if (module.getLuckPermsHook() != null && module.getLuckPermsHook().isEnabled()) {
// Нет кастомного префикса, проверяем LuckPerms
String lpPrefix = module.getLuckPermsHook().getActivePrefix(player);
if (lpPrefix != null && !lpPrefix.isEmpty()) {

View file

@ -41,7 +41,7 @@ public class PlaceholderAPIHook extends PlaceholderExpansion {
@Override
public @NotNull String getVersion() {
return plugin.getDescription().getVersion();
return plugin.getPluginMeta().getVersion();
}
@Override

View file

@ -3,15 +3,25 @@ package com.loki.lochat.listener;
import com.loki.lochat.api.service.MessageService;
import com.loki.lochat.core.registry.ServiceRegistry;
import com.loki.lochat.util.FoliaUtil;
import io.papermc.paper.event.player.AsyncChatEvent;
import io.papermc.paper.chat.ChatRenderer;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.plugin.java.JavaPlugin;
/**
* Слушатель событий чата (минимальная ответственность)
* Слушатель событий чата с Paper Chat API
* Поддерживает hover tooltips, click actions, mentions
*/
public class ChatEventListener implements Listener {
private final JavaPlugin plugin;
@ -23,15 +33,196 @@ public class ChatEventListener implements Listener {
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onChat(AsyncPlayerChatEvent event) {
event.setCancelled(true);
public void onChat(AsyncChatEvent event) {
Player sender = event.getPlayer();
String message = PlainTextComponentSerializer.plainText().serialize(event.message());
Player player = event.getPlayer();
String message = event.getMessage();
// Проверяем фильтры (мут, кулдаун)
boolean allowed = messageService.processMessage(sender, message);
// Folia-safe обработка
FoliaUtil.runEntityTask(plugin, player, () -> {
messageService.processMessage(player, message);
});
if (!allowed) {
event.setCancelled(true);
return;
}
// Устанавливаем кастомный рендерер
event.renderer(new EnhancedChatRenderer(sender, event.message()));
}
/**
* Продвинутый рендерер чата с hover/click/mentions
*/
private class EnhancedChatRenderer implements ChatRenderer {
private final Player sender;
private final Component originalMessage;
public EnhancedChatRenderer(Player sender, Component originalMessage) {
this.sender = sender;
this.originalMessage = originalMessage;
}
@Override
public Component render(Player source, Component sourceDisplayName, Component message, Audience viewer) {
// Получаем конфиг формата
String format = plugin.getConfig().getString("chat.global.format", "{prefix} {player}: {message}");
String prefix = plugin.getConfig().getString("chat.global.prefix", "[G]");
// Создаём компонент с hover и click
Component playerComponent = createPlayerComponent(source, sourceDisplayName);
// Обрабатываем сообщение
Component processedMessage = message;
// 1. Обрабатываем URL (делаем кликабельными)
processedMessage = processUrls(processedMessage);
// 2. Обрабатываем эмодзи шорткоды (:smile: -> 😊)
processedMessage = processEmojis(processedMessage);
// 3. Обрабатываем упоминания если viewer это Player
if (viewer instanceof Player viewerPlayer) {
processedMessage = processMentions(processedMessage, viewerPlayer);
}
// Собираем финальное сообщение
return Component.text(prefix + " ")
.append(playerComponent)
.append(Component.text(": "))
.append(processedMessage);
}
/**
* Создаёт компонент имени игрока с hover и click
*/
private Component createPlayerComponent(Player player, Component displayName) {
// Проверяем включен ли hover
if (!plugin.getConfig().getBoolean("chat.hover.enabled", true)) {
return displayName.clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " "));
}
// Получаем формат из конфига
java.util.List<String> hoverLines = plugin.getConfig().getStringList("chat.hover.format");
if (hoverLines.isEmpty()) {
// Дефолтный формат если конфиг пустой
hoverLines = java.util.Arrays.asList(
"<gray>Игрок: <white>{player}",
"<gray>Мир: <green>{world}",
"",
"<yellow>▶ Клик: написать ЛС"
);
}
// Строим hover text
net.kyori.adventure.text.TextComponent.Builder hoverBuilder = Component.text();
for (int i = 0; i < hoverLines.size(); i++) {
String line = hoverLines.get(i);
// Заменяем плейсхолдеры
line = line.replace("{player}", player.getName())
.replace("{world}", player.getWorld().getName())
.replace("{ping}", String.valueOf(player.getPing()))
.replace("{gamemode}", player.getGameMode().name())
.replace("{health}", String.valueOf(Math.round(player.getHealth())))
.replace("{food}", String.valueOf(player.getFoodLevel()));
// Парсим MiniMessage
Component lineComponent = com.loki.lochat.utils.ChatFormatter.parse(line);
hoverBuilder.append(lineComponent);
// Добавляем перенос строки если не последняя
if (i < hoverLines.size() - 1) {
hoverBuilder.append(Component.newline());
}
}
// Click: открываем /msg
return displayName
.hoverEvent(HoverEvent.showText(hoverBuilder.build()))
.clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " "));
}
/**
* Обрабатывает упоминания (@player) в сообщении
*/
private Component processMentions(Component message, Player viewer) {
String plainText = PlainTextComponentSerializer.plainText().serialize(message);
// Проверяем упоминание текущего игрока
if (plainText.toLowerCase().contains("@" + viewer.getName().toLowerCase())) {
// Подсвечиваем упоминание
Component highlighted = message.replaceText(builder ->
builder.matchLiteral("@" + viewer.getName())
.replacement(Component.text("@" + viewer.getName(), NamedTextColor.YELLOW, TextDecoration.BOLD))
);
// Воспроизводим звук уведомления
FoliaUtil.runEntityTask(plugin, viewer, () -> {
viewer.playSound(viewer.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.5f);
});
return highlighted;
}
return message;
}
/**
* Обрабатывает URL в сообщении (делает кликабельными)
*/
private Component processUrls(Component message) {
String plainText = PlainTextComponentSerializer.plainText().serialize(message);
// Простой regex для URL
String urlPattern = "(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)";
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(urlPattern);
java.util.regex.Matcher matcher = pattern.matcher(plainText);
Component result = message;
while (matcher.find()) {
String url = matcher.group(1);
// Делаем URL кликабельным с hover
Component urlComponent = Component.text(url, NamedTextColor.AQUA, TextDecoration.UNDERLINED)
.hoverEvent(HoverEvent.showText(Component.text("Открыть: " + url, NamedTextColor.GRAY)))
.clickEvent(ClickEvent.openUrl(url));
result = result.replaceText(builder ->
builder.matchLiteral(url).replacement(urlComponent)
);
}
return result;
}
/**
* Обрабатывает эмодзи шорткоды (:smile: -> ☺)
* Использует только MC-совместимые Unicode символы
*/
private Component processEmojis(Component message) {
Component result = message;
// MC-совместимые Unicode эмодзи
result = result.replaceText(builder -> builder.matchLiteral(":smile:").replacement("☺"));
result = result.replaceText(builder -> builder.matchLiteral(":heart:").replacement("♥"));
result = result.replaceText(builder -> builder.matchLiteral(":fire:").replacement("※"));
result = result.replaceText(builder -> builder.matchLiteral(":star:").replacement("★"));
result = result.replaceText(builder -> builder.matchLiteral(":check:").replacement("✓"));
result = result.replaceText(builder -> builder.matchLiteral(":cross:").replacement("✗"));
result = result.replaceText(builder -> builder.matchLiteral(":skull:").replacement("☠"));
result = result.replaceText(builder -> builder.matchLiteral(":arrow:").replacement("→"));
result = result.replaceText(builder -> builder.matchLiteral(":note:").replacement("♪"));
result = result.replaceText(builder -> builder.matchLiteral(":sun:").replacement("☀"));
result = result.replaceText(builder -> builder.matchLiteral(":moon:").replacement("☾"));
result = result.replaceText(builder -> builder.matchLiteral(":peace:").replacement("☮"));
result = result.replaceText(builder -> builder.matchLiteral(":yin:").replacement("☯"));
result = result.replaceText(builder -> builder.matchLiteral(":flower:").replacement("✿"));
result = result.replaceText(builder -> builder.matchLiteral(":snowflake:").replacement("❄"));
result = result.replaceText(builder -> builder.matchLiteral(":lightning:").replacement("⚡"));
result = result.replaceText(builder -> builder.matchLiteral(":crown:").replacement("♔"));
result = result.replaceText(builder -> builder.matchLiteral(":sword:").replacement("⚔"));
result = result.replaceText(builder -> builder.matchLiteral(":shield:").replacement("⛨"));
result = result.replaceText(builder -> builder.matchLiteral(":diamond:").replacement("◆"));
return result;
}
}
}

View file

@ -1,10 +1,12 @@
package com.loki.lochat.listener;
import com.loki.lochat.api.service.NickService;
import com.loki.lochat.api.service.PlayerDataService;
import com.loki.lochat.core.registry.ServiceRegistry;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
/**
@ -12,9 +14,20 @@ import org.bukkit.event.player.PlayerQuitEvent;
*/
public class PlayerEventListener implements Listener {
private final PlayerDataService playerDataService;
private final NickService nickService;
public PlayerEventListener(ServiceRegistry registry) {
this.playerDataService = registry.get(PlayerDataService.class);
this.nickService = registry.get(NickService.class);
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
// Обновляем displayName с кастомным ником если есть
if (nickService != null) {
nickService.updatePlayerDisplay(player);
}
}
@EventHandler

View file

@ -63,6 +63,22 @@ chat:
sound: true
sound-type: "BLOCK_NOTE_BLOCK_PLING"
# ═══════════════════════════════════════════════════════════════════════════
# HOVER TOOLTIP (наведение на ник)
# ═══════════════════════════════════════════════════════════════════════════
hover:
# Включить hover tooltip при наведении на ник
enabled: true
# Формат tooltip (список строк)
# Плейсхолдеры: {player}, {world}, {ping}, {gamemode}, {health}, {food}
# Поддержка PlaceholderAPI: %placeholder%
format:
- "<gray>Игрок: <white>{player}"
- "<gray>Мир: <green>{world}"
- "<gray>Пинг: <yellow>{ping}ms"
- ""
- "<yellow>▶ Клик: написать ЛС"
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ ГРАДИЕНТНЫЕ НИКИ │
# └─────────────────────────────────────────────────────────────────────────────┘

View file

@ -62,6 +62,12 @@ commands:
permission: chat.announce.use
aliases: [объявление, анонс, фттщгтсу]
nick:
description: Set custom nickname
usage: /nick [nickname|reset]
permission: lochat.nick
aliases: [ник, тшсл]
chatspy:
description: Toggle chat spy mode
usage: /chatspy
@ -232,3 +238,6 @@ permissions:
lochat.heademoji.use:
description: Can use head emojis in chat
default: true
lochat.nick:
description: Can use custom nicknames
default: true