添加了ping延迟功能

This commit is contained in:
叁玖领域 2025-07-25 02:03:31 +08:00
parent f5591e7df3
commit 6f045b681f
38 changed files with 1979 additions and 182 deletions

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ASMIdeaPluginConfiguration">
<asm skipDebug="false" skipFrames="false" skipCode="false" expandFrames="false" />

View File

@ -27,6 +27,8 @@ configurations {
attribute(Attribute.of("net.neoforged.moddevgradle.legacy.minecraft_mappings.v2", String), "named")
}
}
clientModImplementation
implementation.extendsFrom clientModImplementation
}
println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}"
@ -94,10 +96,11 @@ dependencies {
compileOnly 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.spongepowered:mixin:0.8.5:processor'
modImplementation "curse.maven:easy-villagers-400514:3887794"
modImplementation "curse.maven:xaeros-world-map-317780:6538320"
modImplementation "curse.maven:immersive-aircraft-666014:4679496"
modImplementation "curse.maven:modern-ui-352491:5229350"
modCompileOnly "curse.maven:modern-ui-352491:5229350"
modImplementation "curse.maven:iceberg-520110:4035917"
}

View File

@ -1,17 +1,30 @@
package com.leisuretimedock.crossmod;
import com.leisuretimedock.crossmod.command.PingCommand;
import com.leisuretimedock.crossmod.network.NetworkHandler;
import com.leisuretimedock.crossmod.network.PingRequestManager;
import com.leisuretimedock.crossmod.reset.ClientResetManager;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.event.RegisterCommandsEvent;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.event.server.ServerStartedEvent;
import net.minecraftforge.event.server.ServerStoppedEvent;
import net.minecraftforge.eventbus.api.IEventBus;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.IExtensionPoint;
import net.minecraftforge.fml.ModLoadingContext;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
import net.minecraftforge.fml.event.lifecycle.FMLDedicatedServerSetupEvent;
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
import net.minecraftforge.fml.loading.FMLEnvironment;
import net.minecraftforge.network.NetworkConstants;
import org.jetbrains.annotations.Nullable;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.Objects;
import java.util.UUID;
@Mod(CrossTeleportMod.MOD_ID)
public class CrossTeleportMod {
@ -23,14 +36,55 @@ public class CrossTeleportMod {
ModLoadingContext.get().registerExtensionPoint(IExtensionPoint.DisplayTest.class,
() -> new IExtensionPoint.DisplayTest(() -> NetworkConstants.IGNORESERVERONLY, (a, b) -> true));
IEventBus modEventBus = FMLJavaModLoadingContext.get().getModEventBus();
modEventBus.addListener(ClientResetManager::init);
if(!FMLEnvironment.dist.isDedicatedServer()) modEventBus.addListener(ClientResetManager::init);
NetworkHandler.register();
}
@Mod.EventBusSubscriber(modid = MOD_ID, bus = Mod.EventBusSubscriber.Bus.FORGE)
public static class CommonEvents {
@Nullable
public static MinecraftServer server;
@SubscribeEvent
public static void onRegisterCommands(RegisterCommandsEvent event) {
PingCommand.register(event.getDispatcher());
}
@SubscribeEvent
public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
PingRequestManager.monitor(player);
}
}
@SubscribeEvent
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
PingRequestManager.unmonitor(player);
}
}
@SubscribeEvent
public static void onServerStart(ServerStartedEvent event) {
server = event.getServer();
}
@SubscribeEvent
public static void onServerStop(ServerStoppedEvent event) {
server = null;
}
public static ServerPlayer getPlayerByUUID(UUID uuid) {
Objects.requireNonNull(server, "server is null");
return server.getPlayerList().getPlayer(uuid);
}
}
@Mod.EventBusSubscriber(modid = MOD_ID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.FORGE)
public static class ClientEvents {
@SubscribeEvent
public static void onClientSetup(FMLClientSetupEvent event) {
event.enqueueWork(NetworkHandler::register);
}
}
@Mod.EventBusSubscriber(modid = MOD_ID, value = Dist.DEDICATED_SERVER, bus = Mod.EventBusSubscriber.Bus.FORGE)
public static class ServerEvent {
@SubscribeEvent
public static void onServerSetup(FMLDedicatedServerSetupEvent event) {
}
}
}

View File

@ -1,54 +0,0 @@
// 客户端网络处理类CrossMod
package com.leisuretimedock.crossmod;
import io.netty.buffer.Unpooled;
import net.minecraft.client.Minecraft;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.game.ServerboundCustomPayloadPacket;
import net.minecraft.resources.ResourceLocation;
import java.util.Objects;
/**
* NetworkHandler 用于客户端向服务端发送插件消息
* 目前只用 plugin message 方式进行通信
*/
public class NetworkHandler {
// 自定义插件消息通道标识
public static final ResourceLocation CHANNEL_ID = new ResourceLocation(CrossTeleportMod.MOD_ID, "channel");
public static void register() {
// TODO: 未来支持双端注册以便服务器端也能处理相关命令
// 当前仅客户端发送 PluginMessage无需额外注册
}
/**
* 发送自定义插件消息
* @param subChannel 子通道标识
* @param payload 负载数据字节数组
*/
public static void sendPluginMessage(ResourceLocation subChannel, byte[] payload) {
FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer(payload.length));
buf.writeBytes(payload);
// 获取当前连接并发送自定义负载包
Objects.requireNonNull(Minecraft.getInstance().getConnection())
.send(new ServerboundCustomPayloadPacket(subChannel, buf));
}
/**
* 发送客户端已准备好消息示例方法调用具体实现
*/
public static void sendClientReady() {
PluginMessageListener.sendClientReady();
}
/**
* 发送传送请求到代理服务器
* @param serverName 目标服务器名
*/
public static void sendTeleportRequest(String serverName) {
PluginMessageListener.sendTeleport(serverName);
}
}

View File

@ -0,0 +1,140 @@
package com.leisuretimedock.crossmod.client;
import com.leisuretimedock.crossmod.network.PingRequestManager;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.player.Player;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class ClientPingHandler {
private static final long DATA_EXPIRE_TIME = 10000; // 10秒数据有效期
private static final Map<UUID, PingData> pingData = new ConcurrentHashMap<>();
private static PingRequestManager.PingStats lastStats;
private static long lastStatsUpdateTime;
public static void handlePingResults(Map<UUID, Long> pingResults, Map<UUID, Double> averages) {
long now = System.currentTimeMillis();
pingResults.forEach((uuid, ping) -> {
PingData data = pingData.computeIfAbsent(uuid, k -> new PingData());
data.update(ping, averages.getOrDefault(uuid, 0.0), now);
});
// 清理过期的数据(5秒未更新)
long currentTime = System.currentTimeMillis();
pingData.entrySet().removeIf(entry ->
currentTime - entry.getValue().lastUpdateTime > 5000
);
}
public static String getPingDisplayText() {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) return "";
StringBuilder sb = new StringBuilder();
// 显示自己的ping信息
Player localPlayer = mc.player;
if (localPlayer != null) {
PingData selfData = pingData.get(localPlayer.getUUID());
if (selfData != null) {
sb.append("Ping: ").append(selfData.currentPing).append("ms");
sb.append(" (Avg: ").append(String.format("%.1f", selfData.averageLatency)).append("ms)");
}
}
// 显示其他玩家的ping信息(最多3个)
int count = 0;
for (Map.Entry<UUID, PingData> entry : pingData.entrySet()) {
if (count >= 3) break;
if (localPlayer != null && entry.getKey().equals(localPlayer.getUUID())) continue;
Player player = mc.level.getPlayerByUUID(entry.getKey());
if (player != null) {
sb.append("\n");
sb.append(player.getScoreboardName())
.append(": ").append(entry.getValue().currentPing).append("ms");
count++;
}
}
return sb.toString();
}
private static class PingData {
long currentPing;
double averageLatency;
long lastUpdateTime;
void update(long ping, double avgLatency, long timestamp) {
this.currentPing = ping;
this.averageLatency = avgLatency;
this.lastUpdateTime = timestamp;
}
}
// 更新统计数据处理方法
public static void handlePingStats(PingRequestManager.PingStats stats) {
lastStats = stats;
lastStatsUpdateTime = System.currentTimeMillis();
}
// 获取要显示的统计文本
public static String getStatsDisplayText() {
if (lastStats == null || System.currentTimeMillis() - lastStatsUpdateTime > 10000) {
return "网络统计: 数据过期";
}
return String.format("""
网络延迟统计:
平均: %.1fms | 最高: %dms
最低: %dms | 时延: %.1fms
丢包率: %.1f%% | 样本: %d
""",
lastStats.average(),
lastStats.max(),
lastStats.min(),
lastStats.averageLatency(),
lastStats.packetLossRate(),
lastStats.sampleCount()
);
}
// 合并Ping数据和统计数据的显示方法
public static List<String> getCombinedDebugText() {
List<String> lines = new ArrayList<>();
// 添加Ping数据
String pingText = getPingDisplayText();
if (!pingText.isEmpty()) {
lines.addAll(Arrays.asList(pingText.split("\n")));
}
// 添加统计信息
String statsText = getStatsDisplayText();
if (!statsText.isEmpty()) {
lines.add(""); // 空行分隔
lines.addAll(Arrays.asList(statsText.split("\n")));
}
return lines;
}
private static String resolvePlayerName(UUID uuid) {
// 实现从UUID到玩家名的解析
if (Minecraft.getInstance().level != null) {
Player player = Minecraft.getInstance().level.getPlayerByUUID(uuid);
if (player != null) {
return player.getScoreboardName();
}
}
return uuid.toString().substring(0, 8);
}
private static void cleanUpOldData(long currentTime) {
pingData.entrySet().removeIf(entry ->
currentTime - entry.getValue().lastUpdateTime > DATA_EXPIRE_TIME
);
}
}

View File

@ -1,6 +1,7 @@
package com.leisuretimedock.crossmod.client;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import com.leisuretimedock.crossmod.client.gui.CrossServerGui;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraftforge.api.distmarker.Dist;

View File

@ -1,55 +0,0 @@
package com.leisuretimedock.crossmod.client;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiComponent;
import net.minecraft.client.renderer.entity.ItemRenderer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.gui.OverlayRegistry;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
@Mod.EventBusSubscriber(modid = CrossTeleportMod.MOD_ID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.MOD)
public class OverlayRenderer {
private static boolean showOverlay = false;
private static final Minecraft mc = Minecraft.getInstance();
public static boolean isShowOverlay() {
return !showOverlay || mc.player == null || mc.level == null;
}
public static void setShow(boolean show) {
showOverlay = show;
}
@SubscribeEvent
public static void onRender(FMLClientSetupEvent event) {
event.enqueueWork(() -> {
OverlayRegistry.registerOverlayTop(
"tran_server_tip",
(forgeIngameGui, poseStack, v, i, i1) -> {
if ( !showOverlay || mc.player == null || mc.level == null) return;
int x = 10;
int y = 10;
Font font = mc.font;
ItemRenderer itemRenderer = mc.getItemRenderer();
// 1. 原版钟物品
ItemStack clockStack = new ItemStack(Items.CLOCK);
// 2. 渲染钟图标含动画帧
itemRenderer.renderAndDecorateItem(clockStack, x, y);
itemRenderer.renderGuiItemDecorations(mc.font, clockStack, x, y);
// 3. 绘制提示文字
String keyText = KeyBindingHandler.OPEN_GUI_KEY.getTranslatedKeyMessage().getString(); // 可动态从 KeyMapping 获取
String text = "按 [" + keyText.toUpperCase() + "] 打开跨服传送菜单";
GuiComponent.drawString(poseStack,font, text, x + 20, y + 6, 0xFFFFFF);
}
);
});
}
}

View File

@ -1,18 +1,18 @@
package com.leisuretimedock.crossmod.client;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import com.leisuretimedock.crossmod.NetworkHandler;
import com.leisuretimedock.crossmod.client.command.GotoCommand;
import com.leisuretimedock.crossmod.client.overlay.CrossServerTipOverLay;
import com.leisuretimedock.crossmod.client.overlay.PluginCommand;
import com.leisuretimedock.crossmod.network.NetworkHandler;
import com.leisuretimedock.crossmod.reset.ClientResetManager;
import com.mojang.brigadier.arguments.StringArgumentType;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.SimpleChannelInboundHandler;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.client.Minecraft;
import net.minecraft.commands.Commands;
import net.minecraft.network.Connection;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.TextComponent;
import net.minecraft.network.protocol.game.ClientboundCustomPayloadPacket;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.ClientPlayerNetworkEvent;
@ -60,11 +60,11 @@ public class PluginChannelClient {
switch (cmd) {
case OVERLAY_SHOW -> {
log.debug("[CrossTeleportMod] 执行 OVERLAY_SHOW");
OverlayRenderer.setShow(true);
CrossServerTipOverLay.setShow(true);
}
case OVERLAY_HIDE -> {
log.debug("[CrossTeleportMod] 执行 OVERLAY_HIDE");
OverlayRenderer.setShow(false);
CrossServerTipOverLay.setShow(false);
}
}
}, () -> log.error("未知指令: {}", command));
@ -111,16 +111,6 @@ public class PluginChannelClient {
@SubscribeEvent
public static void onRegisterCommand(RegisterClientCommandsEvent event) {
event.getDispatcher().register(
Commands.literal("goto")
.then(Commands.argument("server", StringArgumentType.string())
.executes(ctx -> {
String server = StringArgumentType.getString(ctx, "server");
NetworkHandler.sendTeleportRequest(server);
ctx.getSource().sendSuccess(
new TextComponent("请求传送到 " + server), false);
return 1;
}))
);
GotoCommand.register(event.getDispatcher());
}
}

View File

@ -0,0 +1,24 @@
package com.leisuretimedock.crossmod.client.command;
import com.leisuretimedock.crossmod.network.NetworkHandler;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.TranslatableComponent;
public class GotoCommand {
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
LiteralArgumentBuilder<CommandSourceStack> main = Commands.literal("goto")
.then(Commands.argument("server", StringArgumentType.string())
.executes(ctx -> {
String server = StringArgumentType.getString(ctx, "server");
NetworkHandler.sendTeleportRequest(server);
ctx.getSource().sendSuccess(
new TranslatableComponent("ltd.mod.client.request.goto",server), false);
return 1;
}));
dispatcher.register(main);
}
}

View File

@ -1,6 +1,8 @@
package com.leisuretimedock.crossmod.client;
package com.leisuretimedock.crossmod.client.gui;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import com.leisuretimedock.crossmod.client.overlay.CrossServerTipOverLay;
import com.leisuretimedock.crossmod.client.overlay.PingOverlayManager;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.PoseStack;
import io.netty.buffer.Unpooled;
@ -10,18 +12,21 @@ import net.minecraft.client.gui.components.Checkbox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.TextComponent;
import net.minecraft.network.chat.TranslatableComponent;
import net.minecraft.network.protocol.game.ServerboundCustomPayloadPacket;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.jetbrains.annotations.NotNull;
@OnlyIn(Dist.CLIENT)
public class CrossServerGui extends Screen {
private static final ResourceLocation CHANNEL_ID = new ResourceLocation(CrossTeleportMod.MOD_ID, "teleport");
private static final ResourceLocation LOGO_TEXTURE = new ResourceLocation(CrossTeleportMod.MOD_ID, "textures/ltd_logo.png");
public CrossServerGui() {
super(new TextComponent("跨服菜单"));
super(new TranslatableComponent("ltd.mod.client.menu"));
}
@Override
@ -33,26 +38,36 @@ public class CrossServerGui extends Screen {
int spacing = 5;
addRenderableWidget(new Button(centerX - buttonWidth / 2, centerY - buttonHeight - spacing,
buttonWidth, buttonHeight, new TextComponent("🏰 主城"), btn -> {
buttonWidth, buttonHeight, new TranslatableComponent("ltd.mod.client.menu.button.1"), btn -> {
sendCustomPayload("connect:lobby");
onClose();
}));
addRenderableWidget(new Button(centerX - buttonWidth / 2, centerY,
buttonWidth, buttonHeight, new TextComponent("🌲 生存服"), btn -> {
buttonWidth, buttonHeight, new TranslatableComponent("ltd.mod.client.menu.button.2"), btn -> {
sendCustomPayload("connect:survival");
onClose();
}));
// 添加 Checkbox 控件
Checkbox overlayCheckbox = new Checkbox(centerX - buttonWidth / 2, centerY + buttonHeight + spacing + 5,
150, 20, new TextComponent("显示传送提示"), !OverlayRenderer.isShowOverlay()) {
Checkbox enableCrCheckBox = new Checkbox(centerX - buttonWidth / 2, centerY + buttonHeight + spacing + 5,
150, 20, new TranslatableComponent("ltd.mod.client.menu.checkbox.show_trans_tip"), !CrossServerTipOverLay.isShowOverlay()) {
@Override
public void onPress() {
super.onPress();
OverlayRenderer.setShow(this.selected());
CrossServerTipOverLay.setShow(this.selected());
}
};
addRenderableWidget(overlayCheckbox);
addRenderableWidget(enableCrCheckBox);
// 添加 Checkbox 控件
Checkbox enablePiCheckBox = new Checkbox(centerX - buttonWidth / 2, centerY + buttonHeight + spacing + 25,
150, 20, new TranslatableComponent("ltd.mod.client.menu.checkbox.show_ping_stat"), !PingOverlayManager.isShowOverlay()) {
@Override
public void onPress() {
super.onPress();
PingOverlayManager.setShow(this.selected());
}
};
addRenderableWidget(enablePiCheckBox);
}
private void sendCustomPayload(String message) {

View File

@ -0,0 +1,45 @@
package com.leisuretimedock.crossmod.client.overlay;
import com.leisuretimedock.crossmod.client.KeyBindingHandler;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiComponent;
import net.minecraft.client.renderer.entity.ItemRenderer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraftforge.client.gui.ForgeIngameGui;
import net.minecraftforge.client.gui.IIngameOverlay;
public class CrossServerTipOverLay implements IIngameOverlay {
public static final CrossServerTipOverLay INSTANCE = new CrossServerTipOverLay();
private static boolean showOverlay = false;
private static final Minecraft mc = Minecraft.getInstance();
public static boolean isShowOverlay() {
return !showOverlay || mc.player == null || mc.level == null;
}
public static void setShow(boolean show) {
showOverlay = show;
}
@Override
public void render(ForgeIngameGui forgeIngameGui, PoseStack poseStack, float v, int i, int i1) {
if ( !showOverlay || mc.player == null || mc.level == null) return;
int x = 10;
int y = 10;
Font font = mc.font;
ItemRenderer itemRenderer = mc.getItemRenderer();
// 1. 原版钟物品
ItemStack clockStack = new ItemStack(Items.CLOCK);
// 2. 渲染钟图标含动画帧
itemRenderer.renderAndDecorateItem(clockStack, x, y);
itemRenderer.renderGuiItemDecorations(mc.font, clockStack, x, y);
// 3. 绘制提示文字
String keyText = KeyBindingHandler.OPEN_GUI_KEY.getTranslatedKeyMessage().getString(); // 可动态从 KeyMapping 获取
String text = "按 [" + keyText.toUpperCase() + "] 打开跨服传送菜单";
GuiComponent.drawString(poseStack, font, text, x + 20, y + 6, 0xFFFFFF);
}
}

View File

@ -0,0 +1,27 @@
package com.leisuretimedock.crossmod.client.overlay;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.gui.OverlayRegistry;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
@Mod.EventBusSubscriber(modid = CrossTeleportMod.MOD_ID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.MOD)
public class OverlayRenderer {
@SubscribeEvent
public static void onRender(FMLClientSetupEvent event) {
event.enqueueWork(() -> {
OverlayRegistry.registerOverlayTop(
"cross_server_tip",
CrossServerTipOverLay.INSTANCE
);
OverlayRegistry.registerOverlayTop(
"ping_debug",
PingOverlayManager.INSTANCE
);
});
}
}

View File

@ -0,0 +1,116 @@
package com.leisuretimedock.crossmod.client.overlay;
import com.leisuretimedock.crossmod.client.ClientPingHandler;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiComponent;
import net.minecraftforge.client.gui.ForgeIngameGui;
import net.minecraftforge.client.gui.IIngameOverlay;
import java.util.ArrayList;
import java.util.List;
public class PingOverlayManager implements IIngameOverlay {
public static final PingOverlayManager INSTANCE = new PingOverlayManager();
private static boolean showOverlay = true;
private static final Minecraft mc = Minecraft.getInstance();
public static boolean isShowOverlay() {
return !showOverlay || mc.player == null || mc.level == null;
}
// 配置参数
private static final int MARGIN = 5;
private static final int PADDING = 2;
private static final int BACKGROUND_COLOR = 0x80000000;
private static final int TEXT_COLOR = 0xFFFFFF;
public static void setShow(boolean show) {
showOverlay = show;
}
@Override
public void render(ForgeIngameGui gui, PoseStack poseStack, float partialTick, int width, int height) {
if (!showOverlay || mc.player == null || mc.level == null) {
return;
}
// 获取所有要显示的内容
List<String> allLines = getAllDisplayLines();
if (allLines.isEmpty()) {
return;
}
// 计算渲染位置
Font font = mc.font;
int maxWidth = getMaxLineWidth(font, allLines);
int totalHeight = allLines.size() * font.lineHeight;
// 自动调整位置以避免与其他调试信息重叠
int x = width - MARGIN - maxWidth;
int y = findSuitableYPosition(height, totalHeight);
// 绘制背景
drawBackground(poseStack, x, y, maxWidth, totalHeight, font);
// 绘制文本
drawTextLines(gui, poseStack, font, allLines, x, y);
}
private List<String> getAllDisplayLines() {
List<String> lines = new ArrayList<>();
// 添加Ping信息
String pingText = ClientPingHandler.getPingDisplayText();
if (!pingText.isEmpty()) {
lines.addAll(List.of(pingText.split("\n")));
}
// 添加统计信息
String statsText = ClientPingHandler.getStatsDisplayText();
if (!statsText.isEmpty()) {
if (!lines.isEmpty()) lines.add(""); // 添加分隔空行
lines.addAll(List.of(statsText.split("\n")));
}
return lines;
}
private int getMaxLineWidth(Font font, List<String> lines) {
return lines.stream()
.mapToInt(font::width)
.max()
.orElse(0);
}
private int findSuitableYPosition(int screenHeight, int totalHeight) {
// 基础位置从顶部开始
int baseY = 10;
// 检查是否会与其他调试信息重叠
// 这里可以根据需要添加更复杂的检测逻辑
if (baseY + totalHeight > screenHeight / 2) {
// 如果会重叠则移动到屏幕下半部分
return screenHeight - totalHeight - 30;
}
return baseY;
}
private void drawBackground(PoseStack poseStack, int x, int y, int width, int height, Font font) {
GuiComponent.fill(poseStack,
x - PADDING, y - PADDING,
x + width + PADDING, y + height + PADDING,
BACKGROUND_COLOR);
}
private void drawTextLines(ForgeIngameGui gui, PoseStack poseStack, Font font, List<String> lines, int x, int y) {
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
if (!line.isEmpty()) {
gui.getFont().draw(poseStack, line,
x,
y + i * font.lineHeight,
TEXT_COLOR);
}
}
}
}

View File

@ -1,4 +1,4 @@
package com.leisuretimedock.crossmod.client;
package com.leisuretimedock.crossmod.client.overlay;
import java.util.Arrays;
import java.util.Optional;

View File

@ -0,0 +1,275 @@
package com.leisuretimedock.crossmod.command;
import com.leisuretimedock.crossmod.network.NetworkHandler;
import com.leisuretimedock.crossmod.network.PingRequestManager;
import com.mojang.brigadier.Command;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.EntityArgument;
import net.minecraft.network.chat.TranslatableComponent;
import net.minecraft.server.level.ServerPlayer;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.UUID;
public class PingCommand {
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
LiteralArgumentBuilder<CommandSourceStack> networkping =
Commands.literal("netping")
.requires(source -> source.hasPermission(2))
.executes(context -> executeFullReport(context.getSource()))
.then(Commands.literal("report")
.executes(context -> executeFullReport(context.getSource()))
.then(Commands.argument("players", EntityArgument.players())
.executes(context -> executePlayerReport(
context.getSource(),
EntityArgument.getPlayers(context, "players")
)))
)
.then(Commands.literal("stats")
.executes(context -> executeStatsReport(context.getSource())))
.then(Commands.literal("ping")
.then(Commands.argument("players", EntityArgument.players())
.executes(context -> executePingPlayers(
context.getSource(),
EntityArgument.getPlayers(context, "players")
))
)
.executes(context -> executeSinglePing(context.getSource())))
.then(Commands.literal("monitor")
.then(Commands.argument("players", EntityArgument.players())
.executes(context -> executeToggleMonitoring(
context.getSource(),
EntityArgument.getPlayers(context, "players"),
true
))
)
.executes(context -> executeToggleMonitoring(context.getSource(), true)))
.then(Commands.literal("unmonitor")
.then(Commands.argument("players", EntityArgument.players())
.executes(context -> executeToggleMonitoring(
context.getSource(),
EntityArgument.getPlayers(context, "players"),
false
))
)
.executes(context -> executeToggleMonitoring(context.getSource(), false)))
.then(Commands.literal("multiping")
.then(Commands.argument("players", EntityArgument.players())
.then(Commands.argument("count", IntegerArgumentType.integer(1, PingRequestManager.getMAX_BATCH_PINGS()))
.then(Commands.argument("interval", IntegerArgumentType.integer((int) PingRequestManager.getMIN_PING_INTERVAL(), 5000))
.executes(ctx -> executeMultiPing(
ctx.getSource(),
EntityArgument.getPlayers(ctx, "players"),
IntegerArgumentType.getInteger(ctx, "count"),
IntegerArgumentType.getInteger(ctx, "interval")
))
)
)
.executes(ctx -> executeMultiPing(
ctx.getSource(),
Collections.singleton(ctx.getSource().getPlayerOrException()),
5, // 默认次数
1000 // 默认间隔(ms)
))
)
);
dispatcher.register(networkping);
}
private static int executePlayerReport(CommandSourceStack source, Collection<ServerPlayer> players) throws CommandSyntaxException {
if (players.isEmpty()) {
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.error.no_players"), false);
return 0;
}
ServerPlayer requester = source.getPlayerOrException();
Map<UUID, Long> results = PingRequestManager.getLatestPingsForPlayers(players);
if (results.isEmpty()) {
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.info.no_data"), false);
return Command.SINGLE_SUCCESS;
}
NetworkHandler.sendPingResults(requester, results);
sendTextReport(requester, results);
return Command.SINGLE_SUCCESS;
}
private static int executeFullReport(CommandSourceStack source) throws CommandSyntaxException {
ServerPlayer player = source.getPlayerOrException();
Map<UUID, Long> results = PingRequestManager.getAllLatestPings();
if (results.isEmpty()) {
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.info.no_data"), false);
return Command.SINGLE_SUCCESS;
}
NetworkHandler.sendPingResults(player, results);
sendTextReport(player, results);
// 发送统计信息
PingRequestManager.PingStats stats = PingRequestManager.getGlobalPingStats();
NetworkHandler.sendPingStats(player, stats);
return Command.SINGLE_SUCCESS;
}
private static int executeStatsReport(CommandSourceStack source) throws CommandSyntaxException {
ServerPlayer player = source.getPlayerOrException();
PingRequestManager.PingStats stats = PingRequestManager.getGlobalPingStats();
if (stats.sampleCount() == 0) {
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.info.no_data"), false);
return Command.SINGLE_SUCCESS;
}
NetworkHandler.sendPingStats(player, stats);
sendStatsTextReport(player, stats);
return Command.SINGLE_SUCCESS;
}
private static int executeSinglePing(CommandSourceStack source) throws CommandSyntaxException {
ServerPlayer player = source.getPlayerOrException();
if(!PingRequestManager.isMonitored(player.getUUID())) {
source.sendFailure(new TranslatableComponent("ltd.mod.ping.error.not_monitored.self"));
return -1;
}
PingRequestManager.ping(player);
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.success.ping_self"), false);
return Command.SINGLE_SUCCESS;
}
private static int executePingPlayers(CommandSourceStack source, Collection<ServerPlayer> players) throws CommandSyntaxException {
if (players.isEmpty()) {
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.error.no_players"), false);
return 0;
}
players.forEach(player -> {
if(!PingRequestManager.isMonitored(player.getUUID())) {
source.sendFailure(new TranslatableComponent("ltd.mod.ping.error.not_monitored.other",
player.getScoreboardName()));
}
else {
PingRequestManager.ping(player);
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.success.ping_other",
player.getScoreboardName()), false);
}
});
return Command.SINGLE_SUCCESS;
}
private static int executeMultiPing(CommandSourceStack source,
Collection<ServerPlayer> players,
int count,
int interval) {
if (players.isEmpty()) {
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.error.no_players"), false);
return 0;
}
players.forEach(player -> {
if (PingRequestManager.sendMultiplePings(player, count, interval)) {
source.sendSuccess(
player.getScoreboardName().equals(source.getTextName()) ?
new TranslatableComponent("ltd.mod.ping.success.multiping.start.self", count, interval) :
new TranslatableComponent("ltd.mod.ping.success.multiping.start.other", player.getScoreboardName(), count, interval),
false);
} else {
source.sendFailure(
new TranslatableComponent(
player.getScoreboardName().equals(source.getTextName()) ?
"ltd.mod.ping.error.multiping.fail.self" :
"ltd.mod.ping.error.multiping.fail.other",
player.getScoreboardName()
)
);
}
});
return Command.SINGLE_SUCCESS;
}
private static int executeToggleMonitoring(CommandSourceStack source, boolean monitor) throws CommandSyntaxException {
ServerPlayer player = source.getPlayerOrException();
if (monitor) {
PingRequestManager.monitor(player);
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.success.monitor.self"), false);
} else {
PingRequestManager.unmonitor(player);
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.success.unmonitor.self"), false);
}
return Command.SINGLE_SUCCESS;
}
private static int executeToggleMonitoring(CommandSourceStack source, Collection<ServerPlayer> players, boolean monitor) throws CommandSyntaxException {
if (players.isEmpty()) {
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.error.no_players"), false);
return 0;
}
players.forEach(player -> {
if (monitor) {
PingRequestManager.monitor(player);
source.sendFailure(new TranslatableComponent("ltd.mod.ping.error.not_monitored.other",
player.getScoreboardName()));
} else {
PingRequestManager.unmonitor(player);
source.sendSuccess(new TranslatableComponent("ltd.mod.ping.success.ping_other",
player.getScoreboardName()), false);
}
});
return players.size();
}
private static void sendTextReport(ServerPlayer player, Map<UUID, Long> results) {
player.sendMessage(new TranslatableComponent("ltd.mod.ping.title.report").withStyle(ChatFormatting.GOLD),
player.getUUID());
results.forEach((uuid, ping) -> {
player.sendMessage(
new TranslatableComponent(
"ltd.mod.ping.report.entry",
uuid.toString().substring(0, 8),
ping,
PingRequestManager.getAverageLatency(uuid),
PingRequestManager.getPacketLossRate(uuid)),
player.getUUID()
);
});
}
private static void sendStatsTextReport(ServerPlayer player, PingRequestManager.PingStats stats) {
player.sendMessage(new TranslatableComponent("ltd.mod.ping.title.stats").withStyle(ChatFormatting.GOLD),
player.getUUID());
player.sendMessage(new TranslatableComponent(
"ltd.mod.ping.stats.average", stats.average()), player.getUUID());
player.sendMessage(new TranslatableComponent(
"ltd.mod.ping.stats.max", stats.max()), player.getUUID());
player.sendMessage(new TranslatableComponent(
"ltd.mod.ping.stats.min", stats.max()), player.getUUID());
player.sendMessage(new TranslatableComponent(
"ltd.mod.ping.stats.avg_latency", stats.averageLatency()), player.getUUID());
player.sendMessage(new TranslatableComponent(
"ltd.mod.ping.stats.packet_loss", stats.packetLossRate()), player.getUUID());
player.sendMessage(new TranslatableComponent(
"ltd.mod.ping.stats.sample_count", stats.sampleCount()), player.getUUID());
}
}

View File

@ -0,0 +1,45 @@
package com.leisuretimedock.crossmod.mixin;
import net.minecraftforge.fml.loading.FMLEnvironment;
import org.objectweb.asm.tree.ClassNode;
import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin;
import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
import java.util.List;
import java.util.Set;
public class CrossServerModMixinPlugin implements IMixinConfigPlugin {
@Override
public void onLoad(String s) {
}
@Override
public String getRefMapperConfig() {
return null;
}
@Override
public boolean shouldApplyMixin(String s, String s1) {
return !FMLEnvironment.dist.isDedicatedServer();
}
@Override
public void acceptTargets(Set<String> set, Set<String> set1) {
}
@Override
public List<String> getMixins() {
return null;
}
@Override
public void preApply(String s, ClassNode classNode, String s1, IMixinInfo iMixinInfo) {
}
@Override
public void postApply(String s, ClassNode classNode, String s1, IMixinInfo iMixinInfo) {
}
}

View File

@ -0,0 +1,155 @@
// 客户端网络处理类CrossMod
package com.leisuretimedock.crossmod.network;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import com.leisuretimedock.crossmod.network.toClient.PingMessagePayload;
import com.leisuretimedock.crossmod.network.toClient.PingResultPacket;
import com.leisuretimedock.crossmod.network.toClient.PingStatsPacket;
import com.leisuretimedock.crossmod.network.toServer.PongMessagePayload;
import io.netty.buffer.Unpooled;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.client.Minecraft;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.game.ServerboundCustomPayloadPacket;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.network.NetworkDirection;
import net.minecraftforge.network.NetworkRegistry;
import net.minecraftforge.network.PacketDistributor;
import net.minecraftforge.network.simple.SimpleChannel;
import java.util.*;
/**
* NetworkHandler
* 目前只用 plugin message 方式进行通信
*/
@Slf4j
public class NetworkHandler {
// 自定义插件消息通道标识
public static final ResourceLocation CHANNEL_ID = new ResourceLocation(CrossTeleportMod.MOD_ID, "channel");
private static final String PROTOCOL_VERSION = "1";
public static SimpleChannel CHANNEL;
private static int messageId = 0;
/**
* 注册网络通道和消息处理器
*/
public static void register() {
// 支持双端注册以便服务器端也能处理相关命令
CHANNEL = NetworkRegistry.newSimpleChannel(
new ResourceLocation(CrossTeleportMod.MOD_ID, "main"),
() -> PROTOCOL_VERSION,
PROTOCOL_VERSION::equals,
PROTOCOL_VERSION::equals
);
CHANNEL.registerMessage(
messageId++,
PongMessagePayload.class,
PongMessagePayload::encode,
PongMessagePayload::decode,
PongMessagePayload::handle,
Optional.of(NetworkDirection.PLAY_TO_SERVER)
);
CHANNEL.registerMessage(
messageId++,
PingMessagePayload.class,
PingMessagePayload::encode,
PingMessagePayload::decode,
PingMessagePayload::handle,
Optional.of(NetworkDirection.PLAY_TO_CLIENT)
);
CHANNEL.registerMessage(
messageId++,
PingResultPacket.class,
PingResultPacket::encode,
PingResultPacket::decode,
PingResultPacket::handle
);
CHANNEL.registerMessage(
messageId++,
PingStatsPacket.class,
PingStatsPacket::encode,
PingStatsPacket::decode,
PingStatsPacket::handle
);
}
// 新增发送报告方法
public static void sendPingReport(ServerPlayer player,
Map<UUID, Long> pingResults,
Map<UUID, Double> averageLatencies,
PingRequestManager.PingStats globalStats) {
CHANNEL.send(PacketDistributor.PLAYER.with(() -> player),
new PingResultPacket(pingResults, averageLatencies));
CHANNEL.send(PacketDistributor.PLAYER.with(() -> player),
new PingStatsPacket(globalStats));
}
public static void sendPingResults(ServerPlayer player, Map<UUID, Long> results) {
// 创建平均时延映射
Map<UUID, Double> averageLatencies = new HashMap<>();
// 为每个结果获取平均时延
results.forEach((uuid, ping) -> {
double avgLatency = PingRequestManager.getAverageLatency(uuid);
averageLatencies.put(uuid, avgLatency);
});
// 发送包含ping值和平均时延的数据包
CHANNEL.send(PacketDistributor.PLAYER.with(() -> player),
new PingResultPacket(results, averageLatencies));
}
public static void sendPingStats(ServerPlayer player, PingRequestManager.PingStats stats) {
CHANNEL.send(PacketDistributor.PLAYER.with(() -> player),
new PingStatsPacket(stats));
}
/**
* 发送Ping请求
* @param player 服务器玩家
*/
public static void sendPingRequest(ServerPlayer player, UUID requestId) {
try {
CHANNEL.sendTo(new PingMessagePayload(requestId),
player.connection.getConnection(),
NetworkDirection.PLAY_TO_CLIENT);
} catch (Exception e) {
log.error("发送ping请求失败", e);
}
}
/**
* 发送自定义插件消息
* @param subChannel 子通道标识
* @param payload 负载数据字节数组
*/
public static void sendPluginMessage(ResourceLocation subChannel, byte[] payload) {
FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer(payload.length));
buf.writeBytes(payload);
// 获取当前连接并发送自定义负载包
Objects.requireNonNull(Minecraft.getInstance().getConnection())
.send(new ServerboundCustomPayloadPacket(subChannel, buf));
}
/**
* 发送客户端已准备好消息示例方法调用具体实现
*/
public static void sendClientReady() {
PluginMessageListener.sendClientReady();
}
/**
* 发送传送请求到代理服务器
* @param serverName 目标服务器名
*/
public static void sendTeleportRequest(String serverName) {
PluginMessageListener.sendTeleport(serverName);
}
}

View File

@ -0,0 +1,436 @@
package com.leisuretimedock.crossmod.network;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.network.chat.TranslatableComponent;
import net.minecraft.server.level.ServerPlayer;
import java.util.*;
import java.util.concurrent.*;
@Slf4j
public final class PingRequestManager {
// 配置常量
private static final long DEFAULT_TIMEOUT_MS = 5000;
private static final long CLEANUP_INTERVAL_MS = 60000;
private static final int MAX_PING_HISTORY = 1024;
@Getter
private static final int MAX_BATCH_PINGS = 1024; // 单次批量最大Ping数
@Getter
private static final long MIN_PING_INTERVAL = 50; // 最小间隔时间(ms)
@Getter
private static final long PING_INTERVAL = 1000; // 系统间隔时间(ms)
// 线程安全的存储结构
private static final ConcurrentMap<UUID, PlayerPingData> playerData = new ConcurrentHashMap<>();
private static final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
// 添加定时任务调度器
private static final ScheduledExecutorService pingScheduler = Executors.newSingleThreadScheduledExecutor();
static {
// 定期清理过期请求
scheduler.scheduleAtFixedRate(
PingRequestManager::cleanupExpiredRequests,
CLEANUP_INTERVAL_MS, CLEANUP_INTERVAL_MS,
TimeUnit.MILLISECONDS
);
// Ping任务 (新增)
pingScheduler.scheduleAtFixedRate(() -> {
try {
pingAllMonitoredPlayers();
} catch (Exception e) {
log.error("Ping任务执行失败", e);
}
}, 1000, PING_INTERVAL, TimeUnit.MILLISECONDS);
}
private PingRequestManager() {} // 防止实例化
// ========== 公共API ==========
/**
* 监控玩家并开始收集ping数据
*/
public static void monitor(ServerPlayer player) {
Objects.requireNonNull(player, "Player cannot be null");
playerData.putIfAbsent(player.getUUID(), new PlayerPingData());
}
/**
* 停止监控玩家并清理数据
*/
public static void unmonitor(ServerPlayer player) {
Objects.requireNonNull(player, "Player cannot be null");
playerData.remove(player.getUUID());
}
public static boolean isMonitored(UUID uuid) {
return playerData.containsKey(uuid);
}
/**
* 发起ping请求
*/
public static void ping(ServerPlayer player) {
Objects.requireNonNull(player, "Player cannot be null");
PlayerPingData data = playerData.get(player.getUUID());
if (data == null) return; // 玩家未被监控
UUID requestId = UUID.randomUUID();
long currentTime = System.currentTimeMillis();
synchronized (data) {
data.totalRequests++;
data.activeRequests.put(requestId, currentTime);
}
NetworkHandler.sendPingRequest(player, requestId);
}
/**
* 完成ping请求并计算延迟
*/
public static void complete(ServerPlayer player, UUID requestId) {
Objects.requireNonNull(player, "Player cannot be null");
Objects.requireNonNull(requestId, "Request ID cannot be null");
PlayerPingData data = playerData.get(player.getUUID());
if (data == null) return;
synchronized (data) {
Long startTime = data.activeRequests.remove(requestId);
if (startTime != null) {
long ping = System.currentTimeMillis() - startTime;
data.successfulRequests++;
// 网络拥塞检测
if (ping > DEFAULT_TIMEOUT_MS * 0.8) {
player.sendMessage(new TranslatableComponent("ltd.mod.ping.warn.network_latency"),
player.getUUID());
}
updatePingHistory(data, ping);
}
}
}
/**
* 获取最新ping值
*/
public static Optional<Long> getLatestPing(UUID playerId) {
PlayerPingData data = playerData.get(playerId);
return data != null && !data.pingHistory.isEmpty()
? Optional.of(data.pingHistory.getLast())
: Optional.empty();
}
/**
* 获取ping统计数据包含丢包率
*/
public static PingStats getPingStats(UUID playerId) {
PlayerPingData data = playerData.get(playerId);
if (data == null || data.pingHistory.isEmpty()) {
return PingStats.EMPTY;
}
// 计算基本统计数据
LongSummaryStatistics stats = data.pingHistory.stream()
.mapToLong(Long::longValue)
.summaryStatistics();
// 计算丢包率
double packetLossRate = calculatePacketLossRate(data);
return new PingStats(
stats.getAverage(),
stats.getMax(),
stats.getMin(),
(int) stats.getCount(),
calculateAverageLatency(data.pingHistory),
packetLossRate
);
}
/**
* 获取玩家的平均时延
*/
public static double getAverageLatency(UUID playerId) {
PlayerPingData data = playerData.get(playerId);
return data != null ? calculateAverageLatency(data.pingHistory) : 0;
}
/**
* 获取玩家的丢包率
*/
public static double getPacketLossRate(UUID playerId) {
PlayerPingData data = playerData.get(playerId);
return data != null ? calculatePacketLossRate(data) : 0;
}
/**
* 获取全局Ping统计数据包含丢包率
*/
public static PingStats getGlobalPingStats() {
List<Long> allPings = new ArrayList<>();
int totalRequests = 0;
int successfulRequests = 0;
for (PlayerPingData data : playerData.values()) {
synchronized (data) {
allPings.addAll(data.pingHistory);
totalRequests += data.totalRequests;
successfulRequests += data.successfulRequests;
}
}
if (allPings.isEmpty()) {
return PingStats.EMPTY;
}
LongSummaryStatistics stats = allPings.stream()
.mapToLong(Long::longValue)
.summaryStatistics();
// 计算全局丢包率
double globalPacketLossRate = totalRequests > 0
? (1 - (double) successfulRequests / totalRequests) * 100
: 0;
return new PingStats(
stats.getAverage(),
stats.getMax(),
stats.getMin(),
(int) stats.getCount(),
calculateAverageLatency(allPings),
globalPacketLossRate
);
}
/**
* 获取所有玩家的最新ping值
*/
public static Map<UUID, Long> getAllLatestPings() {
Map<UUID, Long> results = new HashMap<>();
playerData.forEach((uuid, data) -> {
synchronized (data) {
if (!data.pingHistory.isEmpty()) {
results.put(uuid, data.pingHistory.getLast());
}
}
});
return results;
}
/**
* 获取指定玩家的最新ping数据
* @param players 要查询的玩家集合
* @return 包含玩家UUID和最新ping时间的Map
*/
public static Map<UUID, Long> getLatestPingsForPlayers(Collection<ServerPlayer> players) {
Map<UUID, Long> results = new HashMap<>();
for (ServerPlayer player : players) {
UUID uuid = player.getUUID();
PlayerPingData data = playerData.get(uuid);
if (data != null && !data.pingHistory.isEmpty()) {
synchronized (data) {
results.put(uuid, data.pingHistory.getLast());
}
}
}
return results;
}
/**
* 批量发送Ping请求
* @param player 目标玩家
* @param count 要发送的Ping包数量(1-10)
* @param intervalMs 发送间隔(毫秒,最少100ms)
* @return 是否成功开始发送
*/
public static boolean sendMultiplePings(ServerPlayer player, int count, long intervalMs) {
// 参数验证
if (count < 1 || count > MAX_BATCH_PINGS || intervalMs < MIN_PING_INTERVAL) {
return false;
}
PlayerPingData data = playerData.get(player.getUUID());
if (data == null) return false;
synchronized (data) {
// 检查现有请求量
if (data.activeRequests.size() > MAX_BATCH_PINGS || data.batchInProgress > 0) {
return false;
}
data.batchInProgress = count;
}
// 开始发送
for (int i = 0; i < count; i++) {
final int attempt = i + 1;
scheduler.schedule(() -> {
synchronized (data) {
if (data.batchInProgress == 0) return; // 已取消
UUID requestId = UUID.randomUUID();
data.totalRequests++;
data.activeRequests.put(requestId, System.currentTimeMillis());
NetworkHandler.sendPingRequest(player, requestId);
if (attempt == count) {
data.batchInProgress = 0;
player.sendMessage(
new TranslatableComponent("ltd.mod.ping.success.multiping.complete", count),
player.getUUID());
}
}
}, i * Math.max(intervalMs, MIN_PING_INTERVAL), TimeUnit.MILLISECONDS);
}
return true;
}
/**
* 取消该玩家的所有进行中的Ping请求
*/
public static void cancelPings(ServerPlayer player) {
PlayerPingData data = playerData.get(player.getUUID());
if (data != null) {
synchronized (data) {
data.activeRequests.clear();
data.batchInProgress = 0;
}
}
}
// ========== 内部方法 ==========
// 新增方法Ping所有被监控玩家
private static void pingAllMonitoredPlayers() {
playerData.keySet().forEach(uuid -> {
ServerPlayer player = CrossTeleportMod.CommonEvents.getPlayerByUUID(uuid);
if (player != null && player.isAlive()) {
ping(player);
// 每10次Ping发送一次报告
PlayerPingData data = playerData.get(uuid);
if (data != null && data.totalRequests % 10 == 0) {
sendReportToClient(player);
}
}
});
}
// 改进的发送报告方法
private static void sendReportToClient(ServerPlayer player) {
Map<UUID, Long> latestPings = new HashMap<>();
Map<UUID, Double> averages = new HashMap<>();
playerData.forEach((uuid, data) -> {
synchronized (data) {
if (!data.pingHistory.isEmpty()) {
latestPings.put(uuid, PingRequestManager.getLatestPing(uuid).orElse(-1L));
averages.put(uuid, PingRequestManager.calculateAverageLatency(data.pingHistory));
}
}
});
// 添加全局统计
PingStats globalStats = getGlobalPingStats();
NetworkHandler.sendPingReport(player, latestPings, averages, globalStats);
}
private static void updatePingHistory(PlayerPingData data, long ping) {
data.addPing(ping);
}
private static void cleanupExpiredRequests() {
long currentTime = System.currentTimeMillis();
playerData.forEach((playerId, data) -> {
synchronized (data) {
data.activeRequests.entrySet().removeIf(entry ->
currentTime - entry.getValue() > DEFAULT_TIMEOUT_MS
);
// 将超时的请求计入丢包统计
int expiredCount = data.activeRequests.size();
if (expiredCount > 0) {
data.totalRequests += expiredCount;
data.activeRequests.clear();
}
}
});
}
private static double calculateAverageLatency(Collection<Long> pingHistory) {
if (pingHistory.isEmpty()) return 0;
double total = 0;
double weightSum = 0;
int i = 1;
for (Long ping : pingHistory) {
double weight = 1.0 / i;
total += ping * weight;
weightSum += weight;
i++;
}
return total / weightSum;
}
private static double calculatePacketLossRate(PlayerPingData data) {
synchronized (data) {
if (data.totalRequests == 0) return 0;
return (1 - (double) data.successfulRequests / data.totalRequests) * 100;
}
}
// ========== 数据结构 ==========
private static class PlayerPingData {
final LinkedList<Long> pingHistory = new LinkedList<>();
final Map<UUID, Long> activeRequests = new ConcurrentHashMap<>();
volatile double currentLatency = 0;
int totalRequests = 0;
int successfulRequests = 0;
int batchInProgress = 0;
synchronized void addPing(long ping) {
pingHistory.add(ping);
if (pingHistory.size() > MAX_PING_HISTORY) {
pingHistory.removeFirst();
}
currentLatency = calculateAverageLatency(pingHistory);
}
private double calculateAverageLatency(Collection<Long> pings) {
if (pings.isEmpty()) return 0;
double total = 0;
double weightSum = 0;
int i = 1;
for (Long ping : pings) {
double weight = 1.0 / i;
total += ping * weight;
weightSum += weight;
i++;
}
return total / weightSum;
}
}
public record PingStats(
double average,
long max,
long min,
int sampleCount,
double averageLatency,
double packetLossRate
) {
public static final PingStats EMPTY = new PingStats(0, 0, 0, 0, 0, 0);
}
}

View File

@ -1,4 +1,4 @@
package com.leisuretimedock.crossmod;
package com.leisuretimedock.crossmod.network;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.client.Minecraft;

View File

@ -0,0 +1,34 @@
package com.leisuretimedock.crossmod.network.toClient;
import com.leisuretimedock.crossmod.network.toServer.PongMessagePayload;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.network.NetworkEvent;
import java.util.UUID;
import java.util.function.Supplier;
import static com.leisuretimedock.crossmod.network.NetworkHandler.CHANNEL;
//Server -> Client
public record PingMessagePayload(UUID requestId) {
public static void encode(PingMessagePayload payload, FriendlyByteBuf buf) {
buf.writeLong(payload.requestId().getMostSignificantBits());
buf.writeLong(payload.requestId().getLeastSignificantBits());
}
public static PingMessagePayload decode(FriendlyByteBuf buf) {
long mostSignificantBits = buf.readLong();
long leastSignificantBits = buf.readLong();
return new PingMessagePayload(new UUID(mostSignificantBits, leastSignificantBits));
}
//客户端处理
public static void handle(PingMessagePayload msg, Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> {
// 客户端收到ping请求立即返回pong
CHANNEL.sendToServer(new PongMessagePayload(msg.requestId));
}));
ctx.get().setPacketHandled(true);
}
}

View File

@ -0,0 +1,57 @@
package com.leisuretimedock.crossmod.network.toClient;
import com.leisuretimedock.crossmod.client.ClientPingHandler;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraftforge.network.NetworkEvent;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
public class PingResultPacket {
private final long timestamp;
private final Map<UUID, Long> pingResults; // UUID -> ping值
private final Map<UUID, Double> averageLatencies; // UUID -> 平均时延
public PingResultPacket(Map<UUID, Long> pingResults, Map<UUID, Double> averageLatencies) {
this.timestamp = System.currentTimeMillis();
this.pingResults = new HashMap<>(pingResults);
this.averageLatencies = new HashMap<>(averageLatencies);
}
public PingResultPacket(FriendlyByteBuf buf) {
this.timestamp = System.currentTimeMillis();
int size = buf.readVarInt();
this.pingResults = new HashMap<>(size);
this.averageLatencies = new HashMap<>(size);
for (int i = 0; i < size; i++) {
UUID uuid = buf.readUUID();
long ping = buf.readLong();
double avgLatency = buf.readDouble();
pingResults.put(uuid, ping);
averageLatencies.put(uuid, avgLatency);
}
}
public static PingResultPacket decode(FriendlyByteBuf buf) {
return new PingResultPacket(buf);
}
public void encode(FriendlyByteBuf buf) {
buf.writeVarInt(pingResults.size());
pingResults.forEach((uuid, ping) -> {
buf.writeUUID(uuid);
buf.writeLong(ping);
buf.writeDouble(averageLatencies.getOrDefault(uuid, 0.0));
});
}
public void handle(Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> {
// 检查数据时效性(5秒内有效)
if (System.currentTimeMillis() - timestamp < 5000) {
ClientPingHandler.handlePingResults(pingResults, averageLatencies);
}
});
ctx.get().setPacketHandled(true);
}
}

View File

@ -0,0 +1,46 @@
package com.leisuretimedock.crossmod.network.toClient;
import com.leisuretimedock.crossmod.client.ClientPingHandler;
import com.leisuretimedock.crossmod.network.PingRequestManager;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraftforge.network.NetworkEvent;
import java.util.function.Supplier;
public class PingStatsPacket {
private final PingRequestManager.PingStats stats;
public PingStatsPacket(PingRequestManager.PingStats stats) {
this.stats = stats;
}
public static PingStatsPacket decode(FriendlyByteBuf buf) {
return new PingStatsPacket(
new PingRequestManager.PingStats(
buf.readDouble(),
buf.readLong(),
buf.readLong(),
buf.readInt(),
buf.readDouble(),
buf.readDouble()
)
);
}
public void encode(FriendlyByteBuf buf) {
buf.writeDouble(stats.average());
buf.writeLong(stats.max());
buf.writeLong(stats.min());
buf.writeInt(stats.sampleCount());
buf.writeDouble(stats.averageLatency());
buf.writeDouble(stats.packetLossRate());
}
public void handle(Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> {
ClientPingHandler.handlePingStats(stats);
});
ctx.get().setPacketHandled(true);
}
}

View File

@ -0,0 +1,66 @@
package com.leisuretimedock.crossmod.network.toClient;
import com.leisuretimedock.crossmod.mixin.AccessorMinecraft;
import com.leisuretimedock.crossmod.reset.ClientResetManager;
import com.leisuretimedock.crossmod.reset.ResetHelper;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientHandshakePacketListenerImpl;
import net.minecraft.network.Connection;
import net.minecraft.network.ConnectionProtocol;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.TranslatableComponent;
import net.minecraftforge.network.*;
import java.util.function.Supplier;
@Slf4j
@Setter
@Getter
public class ResetPacket extends HandshakeMessages.C2SAcknowledge {
private int loginIndex;
public ResetPacket() {
super();
}
public static ResetPacket decode(FriendlyByteBuf ignoredBuf) {
return new ResetPacket();
}
public void encode(FriendlyByteBuf buf) {
}
public static void handler(HandshakeHandler ignoredHandler, ResetPacket ignoredMsg, Supplier<NetworkEvent.Context> ctxSupplier) {
NetworkEvent.Context ctx = ctxSupplier.get();
ClientResetManager.isNegotiating.set(true);
Connection conn = ctx.getNetworkManager();
if (ctx.getDirection() != NetworkDirection.LOGIN_TO_CLIENT && ctx.getDirection() != NetworkDirection.PLAY_TO_CLIENT) {
conn.disconnect(new TranslatableComponent("ltd.mod.client.invalid_packet"));
return;
}
if (ResetHelper.clearClient(ctx)) {
NetworkHooks.registerClientLoginChannel(conn);
conn.setProtocol(ConnectionProtocol.LOGIN);
conn.setListener(new ClientHandshakePacketListenerImpl(
conn, Minecraft.getInstance(), null, s -> {}
));
((AccessorMinecraft) Minecraft.getInstance()).setPendingConnection(conn);
try {
ClientResetManager.handshakeChannel.reply(
new HandshakeMessages.C2SAcknowledge(),
ClientResetManager.contextConstructor.newInstance(conn, NetworkDirection.LOGIN_TO_CLIENT, 98)
);
} catch (Exception e) {
log.error("Failed to send acknowledgment", e);
conn.disconnect(new TranslatableComponent("ltd.mod.client.error.handshake"));
}
}
ctx.setPacketHandled(true);
}
}

View File

@ -0,0 +1,29 @@
package com.leisuretimedock.crossmod.network.toServer;
import com.leisuretimedock.crossmod.network.PingRequestManager;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraftforge.network.NetworkEvent;
import java.util.UUID;
import java.util.function.Supplier;
//Server
public record PongMessagePayload(UUID requestId) {
public static void encode(PongMessagePayload payload, FriendlyByteBuf buf) {
buf.writeLong(payload.requestId().getMostSignificantBits());
buf.writeLong(payload.requestId().getLeastSignificantBits());
}
public static PongMessagePayload decode(FriendlyByteBuf buf) {
long mostSignificantBits = buf.readLong();
long leastSignificantBits = buf.readLong();
return new PongMessagePayload(new UUID(mostSignificantBits, leastSignificantBits));
}
//服务器处理
public static void handle(PongMessagePayload msg, Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> {
PingRequestManager.complete(ctx.get().getSender(),msg.requestId);
});
ctx.get().setPacketHandled(true);
}
}

View File

@ -1,10 +1,16 @@
package com.leisuretimedock.crossmod.reset;
import com.leisuretimedock.crossmod.network.toClient.ResetPacket;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.network.Connection;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent;
import net.minecraftforge.fml.util.ObfuscationReflectionHelper;
import net.minecraftforge.network.*;
import net.minecraftforge.network.HandshakeHandler;
import net.minecraftforge.network.NetworkConstants;
import net.minecraftforge.network.NetworkDirection;
import net.minecraftforge.network.NetworkEvent;
import net.minecraftforge.network.simple.SimpleChannel;
import java.lang.reflect.Constructor;
@ -12,6 +18,7 @@ import java.lang.reflect.Field;
import java.util.concurrent.atomic.AtomicBoolean;
@Slf4j
@OnlyIn(Dist.CLIENT)
public class ClientResetManager {
public static final Field handshakeField;
public static final Constructor<NetworkEvent.Context> contextConstructor;

View File

@ -2,11 +2,8 @@ package com.leisuretimedock.crossmod.reset;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.ConnectScreen;
import net.minecraft.client.gui.screens.GenericDirtMessageScreen;
import net.minecraft.client.multiplayer.ServerData;
import net.minecraft.client.multiplayer.resolver.ServerAddress;
import net.minecraft.network.chat.TextComponent;
import net.minecraft.network.chat.TranslatableComponent;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;

View File

@ -1,9 +1,41 @@
{
"ltd.mod.client.name.trans_server": "LTD Cross Server Mod",
"ltd.mod.client.key": "Open LTD Cross Server Menu",
"ltd.mod.client.menu": "Cross Server Menu",
"ltd.mod.client.menu.button.1": "H Hub",
"ltd.mod.client.menu.button.2": "S Survival",
"ltd.mod.client.menu.checkbox.show_trans_tip": "Show cross server tip overlay",
"ltd.mod.client.menu.checkbox.show_ping_stat": "Show ping stat overlay",
"ltd.mod.client.negotiating": "Negotiating...",
"ltd.mod.client.failed.reset_connection": "Failed to reset connection.",
"ltd.mod.client.error.handshake": "Handshake error",
"ltd.mod.client.invalid_reset_packet": "Invalid reset packet",
"ltd.mod.client.invalid_packet": "Invalid packet"
"ltd.mod.client.invalid_packet": "Invalid packet",
"ltd.mod.client.request.goto": "Request goto %s",
"ltd.mod.ping.error.no_players": "No valid players specified",
"ltd.mod.ping.info.no_data": "No valid ping data available",
"ltd.mod.ping.error.not_monitored.self": "You are not being monitored, please enable monitoring first",
"ltd.mod.ping.error.not_monitored.other": "Player %s is not being monitored, please enable monitoring first",
"ltd.mod.ping.success.ping_self": "Sent ping request to yourself",
"ltd.mod.ping.success.ping_other": "Sent ping request to player %s",
"ltd.mod.ping.success.monitor.self": "Started monitoring your ping",
"ltd.mod.ping.success.monitor.other": "Started monitoring ping for player %s",
"ltd.mod.ping.success.unmonitor.self": "Stopped monitoring your ping",
"ltd.mod.ping.success.unmonitor.other": "Stopped monitoring ping for player %s",
"ltd.mod.ping.success.multiping.start.self": "Started batch pinging yourself (%d times, %dms interval)",
"ltd.mod.ping.success.multiping.start.other": "Started batch pinging player %s (%d times, %dms interval)",
"ltd.mod.ping.success.multiping.complete": "Completed %d ping requests",
"ltd.mod.ping.error.multiping.fail.self": "Failed to start batch pinging yourself",
"ltd.mod.ping.error.multiping.fail.other": "Failed to start batch pinging player %s",
"ltd.mod.ping.title.report": "=== Network Latency Report ===",
"ltd.mod.ping.report.entry": "Player %s: %dms (Avg: %.1fms, Loss: %.1f%%)",
"ltd.mod.ping.title.stats": "=== Network Latency Statistics ===",
"ltd.mod.ping.stats.average": "Average latency: %.1fms",
"ltd.mod.ping.stats.max": "Maximum latency: %dms",
"ltd.mod.ping.stats.min": "Minimum latency: %dms",
"ltd.mod.ping.stats.avg_latency": "Average delay: %.1fms",
"ltd.mod.ping.stats.packet_loss": "Packet loss rate: %.1f%%",
"ltd.mod.ping.stats.sample_count": "Sample count: %d",
"ltd.mod.ping.warn.network_latency": "Network latency is high, so it is recommended to reduce the ping frequency"
}

View File

@ -1,10 +1,40 @@
{
"ltd.mod.client.name.trans_server": "LTD跨服传送模组",
"ltd.mod.client.key": "打开LTD跨服传送菜单",
"ltd.mod.client.menu": "跨服菜单",
"ltd.mod.client.menu.button.1": "H 主城",
"ltd.mod.client.menu.button.2": "S 生存服",
"ltd.mod.client.menu.checkbox.show_trans_tip": "显示传送提示",
"ltd.mod.client.menu.checkbox.show_ping_stat": "显示Ping状态",
"ltd.mod.client.negotiating": "重定向中 ...",
"ltd.mod.client.failed.reset_connection": "重置链接失败。",
"ltd.mod.client.error.handshake": "握手出错",
"ltd.mod.client.invalid_reset_packet": "无效的重置链接包",
"ltd.mod.client.invalid_packet": "无效的包"
"ltd.mod.client.invalid_packet": "无效的包",
"ltd.mod.client.request.goto": "请求去往 %s",
"ltd.mod.ping.error.no_players": "未指定有效玩家",
"ltd.mod.ping.info.no_data": "暂无有效的ping数据",
"ltd.mod.ping.error.not_monitored.self": "你未在ping监视中请添加监视后再ping",
"ltd.mod.ping.error.not_monitored.other": "玩家%s未在ping监视中请添加监视后再ping",
"ltd.mod.ping.success.ping_self": "已向你自己发送Ping请求",
"ltd.mod.ping.success.ping_other": "已向玩家%s发送Ping请求",
"ltd.mod.ping.success.monitor.self": "已开始监控你自己的ping",
"ltd.mod.ping.success.monitor.other": "已开始监控玩家%s的ping",
"ltd.mod.ping.success.unmonitor.self": "已停止监控你自己的ping",
"ltd.mod.ping.success.unmonitor.other": "已停止监控玩家%s的ping",
"ltd.mod.ping.success.multiping.start.self": "已开始批量Ping你自己 (%d次, 间隔%dms)",
"ltd.mod.ping.success.multiping.start.other": "已开始批量Ping玩家 %s (%d次, 间隔%dms)",
"ltd.mod.ping.success.multiping.complete": "已完成 %d 个Ping请求",
"ltd.mod.ping.error.multiping.fail.self": "无法开始批量Ping你自己",
"ltd.mod.ping.error.multiping.fail.other": "无法开始批量Ping玩家 %s",
"ltd.mod.ping.title.report": "=== 网络延迟报告 ===",
"ltd.mod.ping.report.entry": "玩家 %s: %dms (平均时延: %.1fms, 丢包率: %.1f%%)",
"ltd.mod.ping.title.stats": "=== 网络延迟统计 ===",
"ltd.mod.ping.stats.average": "平均延迟: %.1fms",
"ltd.mod.ping.stats.max": "最高延迟: %dms",
"ltd.mod.ping.stats.min": "最低延迟: %dms",
"ltd.mod.ping.stats.avg_latency": "平均时延: %.1fms",
"ltd.mod.ping.stats.packet_loss": "丢包率: %.1f%%",
"ltd.mod.ping.stats.sample_count": "样本数量: %d",
"ltd.mod.ping.warn.network_latency": "网络延迟较高建议减少Ping频率"
}

View File

@ -1,11 +1,16 @@
{
"required": true,
"package": "com.leisuretimedock.crossmod.mixin",
"plugin": "com.leisuretimedock.crossmod.mixin.CrossServerModMixinPlugin",
"compatibilityLevel": "JAVA_17",
"refmap": "ltdcrossteleport.refmap.json",
"mixins": [
"AccessorMinecraft",
"MixinMUINetWorkHandler",
"ModListSpoofMixin"
],
"minVersion": "0.8"
"minVersion": "0.8",
"injectors": {
"defaultRequire": 1
}
}

View File

@ -10,6 +10,7 @@ repositories {
mavenCentral()
maven { url 'https://repo.velocitypowered.com/releases/' }
maven { url 'https://repo.lucko.me/' } // LuckPerms
maven { url "https://repo.william278.net/releases" } //
}
base {
archivesName = plugin_name
@ -22,6 +23,7 @@ dependencies {
implementation("org.spongepowered:configurate-yaml:4.1.2")
annotationProcessor 'com.velocitypowered:velocity-api:3.2.0-SNAPSHOT'
compileOnly 'net.luckperms:api:5.4' // LuckPerms API
implementation "net.william278:velocityscoreboardapi:1.1.3"
}
shadowJar {

View File

@ -2,8 +2,10 @@ package com.leisuretimedock.crossplugin;
import com.google.inject.Inject;
import com.leisuretimedock.crossplugin.command.ReloadConfigCommand;
import com.leisuretimedock.crossplugin.listener.PingMessageListener;
import com.leisuretimedock.crossplugin.listener.PluginMessageListener;
import com.leisuretimedock.crossplugin.manager.ConfigManager;
import com.leisuretimedock.crossplugin.manager.PingManager;
import com.leisuretimedock.crossplugin.messages.I18n;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
@ -12,10 +14,13 @@ import com.velocitypowered.api.plugin.PluginContainer;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import org.slf4j.Logger;
import org.slf4j.Marker;
import org.slf4j.MarkerFactory;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
@Plugin(
id = Static.PLUGIN_ID,
@ -24,21 +29,27 @@ import java.util.Locale;
authors = "R3944Realms"
)
public class CrossPlugin {
public static final Marker CROSS_TELEPORT_MOD = MarkerFactory.getMarker("[CrossTeleportMod]");
private final ProxyServer server;
public final Logger logger;
public final PluginMessageListener listener;
public final PluginMessageListener pluginMessageListener;
public final PingMessageListener pingMessageListener;
public static boolean isLuckPermsEnabled;
public final PluginContainer pluginContainer;
public final ConfigManager config;
public final PingManager pingManager;
@Inject
public CrossPlugin(ProxyServer server, Logger logger, @DataDirectory Path dataDirectory ,PluginContainer pluginContainer) throws IOException {
public CrossPlugin(ProxyServer server, Logger logger, @DataDirectory Path dataDirectory , PluginContainer pluginContainer) throws IOException {
this.server = server;
this.logger = logger;
ConfigManager config = new ConfigManager(dataDirectory);
this.config = new ConfigManager(dataDirectory);
this.pingMessageListener = new PingMessageListener(config);
this.pingManager = new PingManager(server, config);
I18n.addBundle(Locale.US);
I18n.addBundle(Locale.SIMPLIFIED_CHINESE);
I18n.init();
this.listener = new PluginMessageListener(server, logger, config);
this.pluginMessageListener = new PluginMessageListener(server, logger, config);
this.pluginContainer = pluginContainer;
server.getCommandManager().register(
server.getCommandManager()
@ -53,10 +64,22 @@ public class CrossPlugin {
@Subscribe
public void onProxyInit(ProxyInitializeEvent event) {
server.getChannelRegistrar().register(PluginMessageListener.CHANNEL_ID, PluginMessageListener.TELEPORT_ID);
server.getEventManager().register(this, listener);
server.getChannelRegistrar().register(
PluginMessageListener.CHANNEL_ID,
PluginMessageListener.TELEPORT_ID,
PingMessageListener.PING_ID,
PingMessageListener.PONG_ID
);
if (config.isEnablePing()) {
server.getEventManager().register(this, pluginMessageListener);
server.getEventManager().register(this, pingMessageListener);
server.getScheduler()
.buildTask(this, pingManager::measureAllBackendPing)
.repeat(config.getIntervalPing(), TimeUnit.SECONDS)
.schedule();
}
isLuckPermsEnabled = server.getPluginManager().getPlugin("luckperms").isPresent();
logger.info("[INIT] Plugin initialized, channel registered.");
logger.info(CROSS_TELEPORT_MOD, "[INIT] Plugin initialized, channel registered.");
}

View File

@ -0,0 +1,4 @@
package com.leisuretimedock.crossplugin.command;
public class PingCommand {
}

View File

@ -0,0 +1,68 @@
package com.leisuretimedock.crossplugin.listener;
import com.leisuretimedock.crossplugin.Static;
import com.leisuretimedock.crossplugin.manager.ConfigManager;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import static com.leisuretimedock.crossplugin.CrossPlugin.CROSS_TELEPORT_MOD;
//TODO:
// 1.模组C <-> 模组S
// 2.代理V <-> 模组S
// 3.模组C <-> 代理V
@Slf4j
public class PingMessageListener {
public static final MinecraftChannelIdentifier PING_ID =
MinecraftChannelIdentifier.create(Static.MOD_ID, "ping");
public static final MinecraftChannelIdentifier PONG_ID =
MinecraftChannelIdentifier.create(Static.MOD_ID, "pong");
private final ConfigManager configManager;
public PingMessageListener(ConfigManager configManager) {
this.configManager = configManager;
}
@Subscribe
public void onPluginMessageReceived(PluginMessageEvent event) {
if (!event.getIdentifier().equals(PING_ID)) return;
Player player = (Player) event.getTarget();
handlePingChannel(player, event.getData());
}
/**
* 处理ping子通道
* @param player 玩家对象
* @param data 消息字节数组
*/ //模组C <-> 代理V
private void handlePingChannel(Player player, byte[] data) {
if(configManager.isEnablePingLog()) log.debug(CROSS_TELEPORT_MOD, "Received ping msg from {}: {}", player.getUsername(), data);
try(DataInputStream in = new DataInputStream(new ByteArrayInputStream(data))) {
long clientTime = in.readLong();
long velocityReceiveTime = System.currentTimeMillis();
// 准备响应
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(baos);
out.writeLong(clientTime); // 客户端原始时间戳
out.writeLong(velocityReceiveTime); // Velocity接收时间
out.writeLong(System.currentTimeMillis()); // Velocity发送时间
byte[] response = baos.toByteArray();
player.sendPluginMessage(
PONG_ID,
response
);
} catch (Exception e) {
if(configManager.isEnableErrorLog())log.error(CROSS_TELEPORT_MOD, "failed to handle ping msg from {}: {}", player.getUsername(), e);
}
}
}

View File

@ -30,6 +30,8 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import static com.leisuretimedock.crossplugin.CrossPlugin.CROSS_TELEPORT_MOD;
/**
* 插件消息监听器负责接收客户端发来的插件消息并处理跨服传送Overlay显示等逻辑
@ -60,7 +62,7 @@ public class PluginMessageListener {
this.proxy = proxy;
this.logger = logger;
this.configManager = configManager;
this.serverManager = new ServerManager(proxy);
this.serverManager = new ServerManager(proxy, configManager);
}
/**
@ -87,7 +89,7 @@ public class PluginMessageListener {
private void handleTeleportChannel(Player player, byte[] data) {
// 跳过第一个 byte长度信息后面是 UTF-8 字符串
String raw = new String(data, 1, data.length - 1, StandardCharsets.UTF_8);
logger.debug("[CrossTeleportMod] Received teleport msg from {}: {}", player.getUsername(), raw);
if(configManager.isEnableListenerLog()) logger.debug(CROSS_TELEPORT_MOD, "Received teleport msg from {}: {}", player.getUsername(), raw);
if (raw.startsWith("connect:")) {
// 兼容旧的 connect: 方式映射别名到真实服务器名
@ -105,21 +107,23 @@ public class PluginMessageListener {
* @param data 消息字节数组
*/
private void handlePluginChannel(Player player, byte[] data) {
// 简单日志打印字节长度和十六进制便于调试
log.trace("Received plugin message on channel 'channel' from player {}", player.getUsername());
log.trace("Data length: {}", data.length);
StringBuilder sb = new StringBuilder();
for (byte b : data) {
sb.append(String.format("%02X ", b));
if(configManager.isEnableListenerLog()) {
// 简单日志打印字节长度和十六进制便于调试
log.trace(CROSS_TELEPORT_MOD, "Received plugin message on channel 'channel' from player {}", player.getUsername());
log.trace(CROSS_TELEPORT_MOD, "Data length: {}", data.length);
StringBuilder sb = new StringBuilder();
for (byte b : data) {
sb.append(String.format("%02X ", b));
}
log.trace("Data hex: {}", sb);
}
log.trace("Data hex: {}", sb);
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(data))) {
String command = in.readUTF();
logger.debug("[CrossTeleportMod] Received plugin command from {}: {}", player.getUsername(), command);
if(configManager.isEnableListenerLog()) logger.debug(CROSS_TELEPORT_MOD, "Received plugin command from {}: {}", player.getUsername(), command);
if ("client_ready".equals(command)) {
if (waitingForReady.remove(player)) {
logger.debug("[CrossTeleportMod] {} is ready, sending overlay", player.getUsername());
if(configManager.isEnableListenerLog()) logger.debug(CROSS_TELEPORT_MOD, " {} is ready, sending overlay", player.getUsername());
player.getCurrentServer().ifPresent(i -> {
String name = i.getServerInfo().getName();
boolean contains = configManager.getOverlayServers().contains(name);
@ -130,17 +134,17 @@ public class PluginMessageListener {
});
// TODO: 支持发送自定义服务器列表
} else {
logger.debug("[CrossTeleportMod] Received client_ready from {}, but not in waiting set", player.getUsername());
if(configManager.isEnableListenerLog()) logger.debug(CROSS_TELEPORT_MOD, "Received client_ready from {}, but not in waiting set", player.getUsername());
}
} else if (command.startsWith("teleport:")) {
String server = command.substring("teleport:".length());
tryTeleport(player, server, true);
} else {
logger.warn("[CrossTeleportMod] Unknown command: {}", command);
if(configManager.isEnableListenerLog()) logger.warn(CROSS_TELEPORT_MOD, "Unknown command: {}", command);
}
} catch (IOException e) {
logger.error("[CrossTeleportMod] Failed to parse plugin message from {}", player.getUsername(), e);
if(configManager.isEnableErrorLog()) logger.error(CROSS_TELEPORT_MOD, "Failed to parse plugin message from {}", player.getUsername(), e);
}
}
@ -164,7 +168,7 @@ public class PluginMessageListener {
proxy.getServer(targetServer).ifPresentOrElse(server -> {
player.createConnectionRequest(server).fireAndForget();
logger.info("[CrossTeleportMod] Sent {} to {}", player.getUsername(), targetServer);
if (configManager.isEnableListenerLog()) logger.info(CROSS_TELEPORT_MOD, "Sent {} to {}", player.getUsername(), targetServer);
}, () -> {
player.sendMessage(I18n.translatable(I18nKeyEnum.SERVER_NOT_FOUND,
NamedTextColor.RED, Component.text(targetServer)));
@ -179,7 +183,7 @@ public class PluginMessageListener {
Player player = event.getPlayer();
String currentServer = event.getServer().getServerInfo().getName();
logger.debug("[CrossTeleportMod] Player {} joined server {}", player.getUsername(), currentServer);
if(configManager.isEnableListenerLog()) logger.debug(CROSS_TELEPORT_MOD, "Player {} joined server {}", player.getUsername(), currentServer);
waitingForReady.add(player);
}

View File

@ -1,5 +1,7 @@
package com.leisuretimedock.crossplugin.manager;
import com.leisuretimedock.crossplugin.CrossPlugin;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.spongepowered.configurate.ConfigurationNode;
import org.spongepowered.configurate.yaml.YamlConfigurationLoader;
@ -14,12 +16,31 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import static com.leisuretimedock.crossplugin.CrossPlugin.CROSS_TELEPORT_MOD;
@Slf4j
public class ConfigManager {
private final Path configPath;
private final YamlConfigurationLoader loader;
private final Map<String, String> serverAliases = new ConcurrentHashMap<>();
private final Set<String> overlayServers = ConcurrentHashMap.newKeySet();
@Getter
private boolean enablePing;
@Getter
private long intervalPing;
@Getter
private boolean enableErrorLog = true;
@Getter
private boolean enableListenerLog = true;
@Getter
private boolean enablePingLog = true;
@Getter
private int goodThreshold;
@Getter
private int moderateThreshold;
@Getter
private int badThreshold;
private ConfigurationNode rootNode;
public ConfigManager(Path configDir) throws IOException {
@ -66,11 +87,62 @@ public class ConfigManager {
String name = node.getString();
if (name != null) overlayServers.add(name.toLowerCase());
}
// Load ping setting
ConfigurationNode pingSetting = rootNode.node("ping-setting");
{
ConfigurationNode enable_ping = pingSetting.node("enable-ping");
ConfigurationNode interval = pingSetting.node("interval");
log.info("Loaded {} server aliases from config", serverAliases.size());
log.info("Loaded {} overlay servers from config", overlayServers.size());
if (pingSetting.virtual() || pingSetting.empty()) {
enable_ping.set(true);
interval.set(Long.class, 5);
loader.save(rootNode);
}
enablePing = enable_ping.getBoolean(true);
intervalPing = interval.getLong(5);
// 加载阈值配置
ConfigurationNode thresholdsNode = rootNode.node("ping-setting", "thresholds");
if (thresholdsNode.virtual() || thresholdsNode.empty()) {
// 设置默认阈值
thresholdsNode.node("good").set(100);
thresholdsNode.node("moderate").set(200);
thresholdsNode.node("bad").set(300);
loader.save(rootNode);
}
goodThreshold = thresholdsNode.node("good").getInt(100);
moderateThreshold = thresholdsNode.node("moderate").getInt(200);
badThreshold = thresholdsNode.node("bad").getInt(300);
// 验证阈值有效性
validateThresholds();
}
// Load log setting
ConfigurationNode logInfoSetting = rootNode.node("log-info-setting");
{
ConfigurationNode enable_error_log = logInfoSetting.node("enable-error-log");
ConfigurationNode enable_listener_log = logInfoSetting.node("enable-listener-log");
ConfigurationNode enable_ping_log = logInfoSetting.node("enable-ping-log");
if (logInfoSetting.virtual() || logInfoSetting.empty()) {
enable_error_log.set(true);
enable_listener_log.set(true);
enable_ping_log.set(true);
loader.save(rootNode);
}
enableErrorLog = enable_error_log.getBoolean(true);
enableListenerLog = enable_listener_log.getBoolean(true);
enablePingLog = enable_ping_log.getBoolean(true);
}
log.info(CROSS_TELEPORT_MOD, "Loaded {} server aliases from config", serverAliases.size());
log.info(CROSS_TELEPORT_MOD, "Loaded {} overlay servers from config", overlayServers.size());
log.info(CROSS_TELEPORT_MOD, "Loaded log setting from config ,error-log:{}, listener-log;{}, ping-log:{}", enableErrorLog, enableListenerLog, enablePingLog);
} catch (IOException e) {
log.error("Failed to load configuration from {}", configPath, e);
log.error(CROSS_TELEPORT_MOD, "Failed to load configuration from {}", configPath, e);
throw e;
}
}
@ -82,6 +154,16 @@ public class ConfigManager {
load();
}
/**
* Validate Thresholds valye
*/
private void validateThresholds() {
if (goodThreshold >= moderateThreshold || moderateThreshold >= badThreshold) {
throw new IllegalStateException("Invalid latency thresholds configuration: " +
"must be good < moderate < bad");
}
}
/**
* Saves the current configuration to disk
*/
@ -143,7 +225,7 @@ public class ConfigManager {
throw new IOException("Missing embedded config.yml in resources!");
}
Files.copy(in, configPath);
log.info("Default config.yml copied to {}", configPath);
log.info(CROSS_TELEPORT_MOD, "Default config.yml copied to {}", configPath);
}
}
}

View File

@ -0,0 +1,60 @@
package com.leisuretimedock.crossplugin.manager;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.Map;
import static com.leisuretimedock.crossplugin.CrossPlugin.CROSS_TELEPORT_MOD;
@Slf4j
public class PingManager {
private final ProxyServer server;
private final Map<String, Long> serverPingResults = new HashMap<>();
private final ConfigManager configManager;
public PingManager(ProxyServer server, ConfigManager configManager) {
this.server = server;
this.configManager = configManager;
}
/**
* 测量所有后端服务器的Ping值
*/
public void measureAllBackendPing() {
server.getAllServers().forEach(serverInfo -> _measureBackendPing(serverInfo, System.currentTimeMillis()));
}
/**
* 测量指定后端服务器的Ping值
* @param backendName 后端服务器名称
*/
public void measureBackendPing(String backendName) {
server.getAllServers().forEach(serverInfo -> {
if (serverInfo.getServerInfo().getName().equals(backendName)) {
_measureBackendPing(serverInfo, System.currentTimeMillis());
}
});
}
/**
* 测量单个后端服务器的Ping值
* @param serverInfo 后端服务器信息
* @param startTime 开始时间
*/
private void _measureBackendPing(RegisteredServer serverInfo, long startTime) {
serverInfo.ping().whenComplete((serverPing, throwable) -> {
long endTime = System.currentTimeMillis();
long ping = endTime - startTime;
String name = serverInfo.getServerInfo().getName();
if (throwable == null) {
serverPingResults.put(name, ping);
if(configManager.isEnablePingLog()) log.debug(CROSS_TELEPORT_MOD, "Ping to server {}: {}ms", name, ping);
} else {
if(configManager.isEnablePingLog()) log.warn(CROSS_TELEPORT_MOD, "Failed to ping server {}", name, throwable);
}
});
}
}

View File

@ -11,10 +11,10 @@ import java.util.Optional;
public class ServerManager {
private final ProxyServer proxy;
private final Map<String, ServerInfo> serverMap = new HashMap<>();
public ServerManager(ProxyServer proxy) {
private final ConfigManager configManager;
public ServerManager(ProxyServer proxy, ConfigManager configManager) {
this.proxy = proxy;
this.configManager = configManager;
// 示例静态初始化可跳转服务器
registerServer("lobby", "大厅服务器");
registerServer("survival", "生存服务器");

View File

@ -19,3 +19,38 @@ server-aliases:
# You can add multiple server names
show-overlay-servers:
- lobby
# ----------------------------------------
# Ping Setting / 计算后端服务器、代理服务器与玩家之间的网络延迟
ping-setting:
# 是否启用延迟计算机制
# Enable or disable ping measurement
# 修改后需要重启代理才生效
# Changes require a proxy restart to take effect
enable-ping: true
# Ping测试间隔(秒)
# Ping measurement interval in seconds
# 修改后需要重启代理才生效
# Changes require a proxy restart to take effect
interval: 5
# 延迟阈值配置(毫秒)
# Latency thresholds in milliseconds
thresholds:
good: 100 # 绿色显示 / Display in green
moderate: 200 # 黄色显示 / Display in yellow
bad: 300 # 红色显示 / Display in red
# ----------------------------------------
# Log Settings / 日志记录设置
log-info-setting:
# 是否记录错误日志
# Enable error logging
enable-error-log: true
# 是否记录监听器事件日志
# Enable listener event logging
enable-listener-log: true
# 是否记录Ping测试日志
# Enable ping measurement logging
enable-ping-log: true