This commit is contained in:
叁玖领域 2025-07-21 13:26:45 +08:00
commit 3f75c04731
40 changed files with 1782 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
**/run/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

4
.idea/encodings.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" defaultCharsetForPropertiesFiles="UTF-8" />
</project>

20
.idea/gradle.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="distributionType" value="LOCAL" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="$PROJECT_DIR$/../../../projEnv/gradle/gradle-8.12" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/forge-mod" />
<option value="$PROJECT_DIR$/velocity-plugin" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

21
.idea/misc.xml Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ASMIdeaPluginConfiguration">
<asm skipDebug="false" skipFrames="false" skipCode="false" expandFrames="false" />
<groovy codeStyle="LEGACY" />
</component>
<component name="EntryPointsManager">
<list size="1">
<item index="0" class="java.lang.String" itemvalue="com.google.common.eventbus.Subscribe" />
</list>
<writeAnnotations>
<writeAnnotation name="com.google.inject.Inject" />
</writeAnnotations>
</component>
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

124
.idea/uiDesigner.xml Normal file
View File

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Palette2">
<group name="Swing">
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
</item>
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
</item>
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
</item>
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
<initial-values>
<property name="text" value="Button" />
</initial-values>
</item>
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="RadioButton" />
</initial-values>
</item>
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="CheckBox" />
</initial-values>
</item>
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
<initial-values>
<property name="text" value="Label" />
</initial-values>
</item>
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
</item>
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
</item>
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
<preferred-size width="-1" height="20" />
</default-constraints>
</item>
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
</item>
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
</item>
</group>
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

39
build.gradle Normal file
View File

@ -0,0 +1,39 @@
buildscript {
repositories {
mavenCentral()
gradlePluginPortal()
maven { url = "https://maven.neoforged.net/releases" }
}
}
plugins {
id 'java'
}
allprojects {
repositories {
mavenCentral()
maven { url = "https://maven.neoforged.net/releases" }
maven { url = "https://maven.minecraftforge.net/" }
maven { url = "https://maven.parchmentmc.org" }
maven { url = "https://maven.izzel.io/releases/" }
maven { url = "https://maven.bawnorton.com/releases" }
maven { url 'https://repo.lucko.me/' } // LuckPerms
}
processResources{
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
}
subprojects {
apply plugin: 'java'
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
}
test {
useJUnitPlatform()
}

166
forge-mod/build.gradle Normal file
View File

@ -0,0 +1,166 @@
plugins {
id 'java'
id 'maven-publish'
id 'org.jetbrains.dokka' version '1.9.10'
id 'io.franzbecker.gradle-lombok' version '3.0.0'
id 'com.github.johnrengelman.shadow' version '8.1.1'
id 'net.neoforged.moddev.legacyforge' version '2.0.103'
}
group = mod_group_id
version = "${minecraft_version}-${mod_version}"
java {
toolchain.languageVersion = JavaLanguageVersion.of(17)
}
base {
archivesName = mod_id
}
configurations {
testImplementation {
canBeConsumed = false
}
testRuntimeClasspath {
canBeConsumed = false
attributes {
attribute(Attribute.of("net.neoforged.moddevgradle.legacy.minecraft_mappings.v2", String), "named")
}
}
}
println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}"
//// Mixin
//mixin {
// add sourceSets.main, "${mod_id}.refmap.json"
// config "${mod_id}.mixins.json"
//}
// LegacyForge
legacyForge {
version = "${minecraft_version}-${forge_version}"
accessTransformers.from "src/main/resources/META-INF/accesstransformer.cfg"
parchment {
minecraftVersion = "${minecraft_version}"
mappingsVersion = "${mapping_lasting_version}"
}
runs {
configureEach {
systemProperty 'forge.logging.console.level', 'debug'
}
clientAuth {
devLogin = true
client()
}
client {
client()
}
data {
data()
}
server {
server()
}
}
mods {
"${mod_id}" {
sourceSet(sourceSets.main)
}
}
}
//
sourceSets {
main {
java.srcDir 'src/main/java'
resources {
srcDirs += 'src/generated/resources'
include '**/**'
exclude '**/*.psd', '.cache'
}
}
}
//
configurations {
library
implementation.extendsFrom library
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.projectlombok:lombok:1.18.24'
}
//
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
dokkaJavadoc {
outputDirectory = file("$buildDir/javadoc")
}
//
jar {
manifest {
attributes([
'Specification-Title' : mod_id,
'Specification-Vendor' : mod_authors,
'Specification-Version' : '1',
'Implementation-Title' : project.name,
'Implementation-Version' : archiveVersion,
'Implementation-Vendor' : mod_authors,
'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"),
// 'MixinConfigs' : "${mod_id}.mixin.json"
])
}
finalizedBy 'reobfJar'
}
processResources {
def props = [
minecraft_version : minecraft_version,
minecraft_version_range: minecraft_version_range,
forge_version : forge_version,
forge_version_range : forge_version_range,
loader_version_range : loader_version_range,
mod_id : mod_id,
mod_name : mod_name,
mod_license : mod_license,
mod_version : mod_version,
mod_authors : mod_authors,
mod_description : mod_description
]
inputs.properties props
filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) {
expand props + [project: project]
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
// ShadowJar
shadowJar {
outputs.upToDateWhen { false }
}
// Maven
publishing {
publications {
create('mavenJava', MavenPublication) {
artifact jar
}
}
repositories {
maven {
url "file://${project.projectDir}/mcmodsrepo"
}
}
}
processResources{
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

View File

@ -0,0 +1,60 @@
# Sets default memory used for gradle commands. Can be overridden by user or command line properties.
# This is required to provide enough memory for the Minecraft decompilation process.
org.gradle.jvmargs=-Xmx3G
org.gradle.daemon=false
neoForge.parchment.minecraftVersion=1.18.2
neoForge.parchment.mappingsVersion=2022.11.06
## Environment Properties
# The Minecraft version must agree with the Forge version to get a valid artifact
minecraft_version=1.18.2
# The Minecraft version range can use any release version of Minecraft as bounds.
# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly
# as they do not follow standard versioning conventions.
minecraft_version_range=[1.18.2,1.19)
# The Forge version must agree with the Minecraft version to get a valid artifact
forge_version=40.3.0
# The Forge version range can use any version of Forge as bounds or match the loader version range
forge_version_range=[40,)
# The loader version range can only use the major version of Forge/FML as bounds
loader_version_range=[40,)
# The mapping channel to use for mappings.
# The default set of supported mapping channels are ["official", "snapshot", "snapshot_nodoc", "stable", "stable_nodoc"].
# Additional mapping channels can be registered through the "channelProviders" extension in a Gradle plugin.
#
# | Channel | Version | |
# |-----------|----------------------|--------------------------------------------------------------------------------|
# | official | MCVersion | Official field/method names from Mojang mapping files |
# | parchment | YYYY.MM.DD-MCVersion | Open community-sourced parameter names and javadocs layered on top of official |
#
# You must be aware of the Mojang license when using the 'official' or 'parchment' mappings.
# See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md
#
# Parchment is an unofficial project maintained by ParchmentMC, separate from Minecraft Forge.
# Additional setup is needed to use their mappings, see https://parchmentmc.org/docs/getting-started
mapping_channel=parchment
# The mapping version to query from the mapping channel.
# This must match the format required by the mapping channel.
mapping_version=2022.11.06-1.18.2
mapping_lasting_version=2022.11.06
# imgui_version=1.89.0
## Mod Properties
# The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63}
# Must match the String constant located in the main mod class annotated with @Mod.
mod_id=ltdcrossteleport
# The human-readable display name for the mod.
mod_name=Leisure Time Dock Mod
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=MIT
# The mod version. See https://semver.org/
mod_version=0.0.0.1
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
mod_group_id=com.leisuretimedock.crossmod
# The authors of the mod. This is a simple text string that is used for display purposes in the mod list.
mod_authors=R3944realms
# The description of the mod. This is a simple multiline text string that is used for display purposes in the mod list.
mod_description=a Ltd Server's Mod

View File

@ -0,0 +1,30 @@
package com.leisuretimedock.crossmod;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.common.MinecraftForge;
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;
@Mod(CrossTeleportMod.MOD_ID)
public class CrossTeleportMod {
public static final String MOD_ID ="ltdcrossteleport";
public static final ResourceLocation CHANNEL = new ResourceLocation(MOD_ID, "teleport");
public CrossTeleportMod() {
// 注册生命周期事件
ModLoadingContext.get().registerExtensionPoint(IExtensionPoint.DisplayTest.class,
() -> new IExtensionPoint.DisplayTest(() -> "ANY", (a, b) -> true));
}
@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);
}
}
}

View File

@ -0,0 +1,64 @@
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.ClientboundCustomPayloadPacket;
import net.minecraft.network.protocol.game.ServerboundCustomPayloadPacket;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.network.NetworkRegistry;
import net.minecraftforge.network.simple.SimpleChannel;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.Objects;
import static com.leisuretimedock.crossmod.client.PluginChannelClient.CHANNEL_ID;
public class NetworkHandler {
private static final String PROTOCOL_VERSION = "1";
private static SimpleChannel CHANNEL;
public static void register() {
//TODO: 以后会做出双端版本以让游戏服务器端可以允运行代理命令简化些流程
// 不需要注册普通 packet因为我们只用 plugin message
CHANNEL = NetworkRegistry.newSimpleChannel(
new ResourceLocation(CrossTeleportMod.MOD_ID, "teleport"),
() -> PROTOCOL_VERSION,
PROTOCOL_VERSION::equals,
PROTOCOL_VERSION::equals
);
}
public static void sendTeleportMessage(String serverName) {
// 构建 raw plugin message
FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer());
buf.writeUtf(serverName);
Objects.requireNonNull(Minecraft.getInstance().getConnection()).send(
new ServerboundCustomPayloadPacket(
CrossTeleportMod.CHANNEL, buf
)
);
}
public static void sendClientReady() {
if (Minecraft.getInstance().player == null) return;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
try {
dos.writeUTF("client_ready");
dos.flush();
} catch (IOException e) {
throw new RuntimeException(e);
}
byte[] bytes = baos.toByteArray();
Objects.requireNonNull(Minecraft.getInstance().getConnection())
.send(new ServerboundCustomPayloadPacket(CHANNEL_ID, new FriendlyByteBuf(Unpooled.wrappedBuffer(bytes))));
}
}

View File

@ -0,0 +1,101 @@
package com.leisuretimedock.crossmod.client;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.PoseStack;
import io.netty.buffer.Unpooled;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.Button;
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.protocol.game.ServerboundCustomPayloadPacket;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.NotNull;
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("跨服菜单"));
}
@Override
protected void init() {
int centerX = width / 2;
int centerY = height / 2;
int buttonWidth = 150;
int buttonHeight = 20;
int spacing = 5;
addRenderableWidget(new Button(centerX - buttonWidth / 2, centerY - buttonHeight - spacing,
buttonWidth, buttonHeight, new TextComponent("🏰 主城"), btn -> {
sendCustomPayload("connect:lobby");
onClose();
}));
addRenderableWidget(new Button(centerX - buttonWidth / 2, centerY,
buttonWidth, buttonHeight, new TextComponent("🌲 生存服"), btn -> {
sendCustomPayload("connect:survival");
onClose();
}));
// 添加 Checkbox 控件
Checkbox overlayCheckbox = new Checkbox(centerX - buttonWidth / 2, centerY + buttonHeight + spacing + 5,
150, 20, new TextComponent("显示传送提示"), !OverlayRenderer.isShowOverlay()) {
@Override
public void onPress() {
super.onPress();
OverlayRenderer.setShow(this.selected());
}
};
addRenderableWidget(overlayCheckbox);
}
private void sendCustomPayload(String message) {
Minecraft mc = Minecraft.getInstance();
if (mc.getConnection() != null) {
FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer());
buf.writeUtf(message);
mc.getConnection().send(new ServerboundCustomPayloadPacket(CHANNEL_ID, buf));
}
}
@Override
public void render(@NotNull PoseStack poseStack, int mouseX, int mouseY, float partialTicks) {
// 背景
this.renderBackground(poseStack);
// Logo 渲染缩放绘制
renderLogo(poseStack);
// 渲染标题文字
drawCenteredString(poseStack, this.font, this.title.getString(), this.width / 2 + 5, 10, 0xFFFFFF);
// 渲染按钮等组件
super.render(poseStack, mouseX, mouseY, partialTicks);
}
private void renderLogo(PoseStack poseStack) {
RenderSystem.setShader(GameRenderer::getPositionTexShader);
RenderSystem.setShaderTexture(0, LOGO_TEXTURE);
RenderSystem.enableDepthTest();
int logoWidth = 100; // 你可以改成 150200
int logoHeight = 100; // 保持比例缩放
int x = (this.width - logoWidth) / 2;
int y = 15;
blit(poseStack, x, y, 0, 0, logoWidth, logoHeight, logoWidth, logoHeight);
}
@Override
public boolean isPauseScreen() {
return false;
}
}

View File

@ -0,0 +1,31 @@
package com.leisuretimedock.crossmod.client;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.ClientRegistry;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
import org.lwjgl.glfw.GLFW;
@Mod.EventBusSubscriber(modid = CrossTeleportMod.MOD_ID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
public class KeyBindingHandler {
public static final KeyMapping OPEN_GUI_KEY = new KeyMapping("ltd.mod.client.name.trans_server", GLFW.GLFW_KEY_HOME, "ltd.mod.client.key");
@SubscribeEvent
public static void onRegisterKey(FMLClientSetupEvent event) {
event.enqueueWork(() -> ClientRegistry.registerKeyBinding(OPEN_GUI_KEY));
}
@Mod.EventBusSubscriber(modid = CrossTeleportMod.MOD_ID, value = Dist.CLIENT)
public static class KeyHandler {
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase == TickEvent.Phase.END && OPEN_GUI_KEY.consumeClick()) {
Minecraft.getInstance().setScreen(new CrossServerGui());
}
}
}
}

View File

@ -0,0 +1,55 @@
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

@ -0,0 +1,126 @@
package com.leisuretimedock.crossmod.client;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import com.leisuretimedock.crossmod.NetworkHandler;
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.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.ClientPlayerNetworkEvent;
import net.minecraftforge.client.event.RegisterClientCommandsEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import java.util.Objects;
@Slf4j
@Mod.EventBusSubscriber(modid = CrossTeleportMod.MOD_ID, value = Dist.CLIENT)
public class PluginChannelClient {
public static final ResourceLocation CHANNEL_ID = new ResourceLocation(CrossTeleportMod.MOD_ID, "channel");
private static final String HANDLER_NAME = CrossTeleportMod.MOD_ID+":channel";
@SubscribeEvent
public static void onLogin(ClientPlayerNetworkEvent.LoggedInEvent event) {
log.info("[CrossTeleportMod] 玩家登录事件触发");
Connection connection = Objects.requireNonNull(Minecraft.getInstance().getConnection()).getConnection();
ChannelPipeline pipeline = connection.channel().pipeline();
log.info("[CrossTeleportMod] 当前管线内容: {}", pipeline.names());
if (pipeline.get(HANDLER_NAME) == null) {
pipeline.addBefore("packet_handler", HANDLER_NAME, new SimpleChannelInboundHandler<ClientboundCustomPayloadPacket>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, ClientboundCustomPayloadPacket packet) {
log.info("[CrossTeleportMod] 收到插件消息包: {}", packet.getIdentifier());
if (!packet.getIdentifier().equals(CHANNEL_ID)) {
log.warn("[CrossTeleportMod] 未识别插件消息频道: {}", packet.getIdentifier());
return;
}
FriendlyByteBuf buf = packet.getData();
try {
// 先读一个字符串但不使用它,出现空消息
buf.readUtf();
// 再读
String command = buf.readUtf();
log.info("[CrossTeleportMod] 收到指令: {}", command);
Minecraft.getInstance().execute(() -> {
PluginCommand.fromId(command).ifPresentOrElse(cmd -> {
switch (cmd) {
case OVERLAY_SHOW -> {
log.info("[CrossTeleportMod] 执行 OVERLAY_SHOW");
OverlayRenderer.setShow(true);
}
case OVERLAY_HIDE -> {
log.info("[CrossTeleportMod] 执行 OVERLAY_HIDE");
OverlayRenderer.setShow(false);
}
}
}, () -> log.error("未知指令: {}", command));
});
} catch (Exception e) {
log.error("[CrossTeleportMod] 处理插件消息时发生错误: {}", e.getMessage());
}
}
});
log.info("[CrossTeleportMod] 已添加插件消息处理器: {}", HANDLER_NAME);
NetworkHandler.sendClientReady();
}
else {
log.debug("[CrossTeleportMod] 管线中已存在插件消息处理器: {}", HANDLER_NAME);
NetworkHandler.sendClientReady();
}
}
@SubscribeEvent
public static void onLogout(ClientPlayerNetworkEvent.LoggedOutEvent event) {
log.info("[CrossTeleportMod] 玩家注销事件触发");
Connection connection = event.getConnection();
if (connection != null) {
ChannelPipeline pipeline = connection.channel().pipeline();
log.info("[CrossTeleportMod] 当前管线内容: {}", pipeline.names());
if (pipeline.get(HANDLER_NAME) != null) {
pipeline.remove(HANDLER_NAME);
log.info("[CrossTeleportMod] 成功移除插件消息处理器: {}", HANDLER_NAME);
} else {
log.warn("[CrossTeleportMod] 未找到插件消息处理器: {}", HANDLER_NAME);
}
} else {
log.warn("[CrossTeleportMod] 玩家连接为空,无法移除插件处理器");
}
}
@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.sendTeleportMessage(server);
ctx.getSource().sendSuccess(
new TextComponent("请求传送到 " + server), false);
return 1;
}))
);
}
}

View File

@ -0,0 +1,17 @@
package com.leisuretimedock.crossmod.client;
import java.util.Arrays;
import java.util.Optional;
public enum PluginCommand {
OVERLAY_SHOW("overlay:show"),
OVERLAY_HIDE("overlay:hide");
public final String id;
PluginCommand(String id) { this.id = id; }
public static Optional<PluginCommand> fromId(String id) {
return Arrays.stream(values()).filter(cmd -> cmd.id.equals(id)).findFirst();
}
}

View File

@ -0,0 +1,64 @@
# This is an example mods.toml file. It contains the data relating to the loading mods.
# There are several mandatory fields (#mandatory), and many more that are optional (#optional).
# The overall format is standard TOML format, v0.5.0.
# Note that there are a couple of TOML lists in this file.
# Find more information on toml format here: https://github.com/toml-lang/toml
# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml
modLoader="javafml" #mandatory
# A version range to match for said mod loader - for regular FML @Mod it will be the forge version
loaderVersion="${loader_version_range}" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions.
# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties.
# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here.
license="${mod_license}"
# A URL to refer people to when problems occur with this mod
#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional
# A list of mods - how many allowed here is determined by the individual mod loader
[[mods]] #mandatory
# The modid of the mod
modId="${mod_id}" #mandatory
# The version number of the mod
version="${mod_version}" #mandatory
# A display name for the mod
displayName="${mod_name}" #mandatory
# A URL to query for updates for this mod. See the JSON update specification https://docs.minecraftforge.net/en/latest/misc/updatechecker/
#updateJSONURL="https://change.me.example.invalid/updates.json" #optional
# A URL for the "homepage" for this mod, displayed in the mod UI
#displayURL="https://change.me.to.your.mods.homepage.example.invalid/" #optional
# A file name (in the root of the mod JAR) containing a logo for display
logoFile="ltd_logo.png" #optional
# A text field displayed in the mod UI
#credits="" #optional
# A text field displayed in the mod UI
authors="${mod_authors}" #optional
# Display Test controls the display for your mod in the server connection screen
# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod.
# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod.
# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component.
# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value.
# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself.
#displayTest="MATCH_VERSION" # MATCH_VERSION is the default if nothing is specified (#optional)
# The description text for the mod (multi line!) (#mandatory)
description='''${mod_description}'''
# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional.
[[dependencies.${mod_id}]] #optional
# the modid of the dependency
modId="forge" #mandatory
# Does this dependency have to exist - if not, ordering below must be specified
mandatory=true #mandatory
# The version range of the dependency
versionRange="${forge_version_range}" #mandatory
# An ordering relationship for the dependency - BEFORE or AFTER required if the dependency is not mandatory
# BEFORE - This mod is loaded BEFORE the dependency
# AFTER - This mod is loaded AFTER the dependency
ordering="NONE"
# Side this dependency is applied on - BOTH, CLIENT, or SERVER
side="BOTH"
# Here's another dependency
[[dependencies.${mod_id}]]
modId="minecraft"
mandatory=true
# This version range declares a minimum of the current minecraft version up to but not including the next major version
versionRange="${minecraft_version_range}"
ordering="NONE"
side="BOTH"

View File

@ -0,0 +1,4 @@
{
"ltd.mod.client.name.trans_server": "LTD跨服传送模组",
"ltd.mod.client.key": "LTD跨服传送按键"
}

View File

@ -0,0 +1,3 @@
{
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

View File

@ -0,0 +1,6 @@
{
"pack": {
"description": "${mod_id} resources",
"pack_format": 9
}
}

4
settings.gradle Normal file
View File

@ -0,0 +1,4 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
}
include(":velocity-plugin","forge-mod")

View File

@ -0,0 +1,43 @@
plugins {
id 'java'
id 'com.github.johnrengelman.shadow' version '7.1.2'
id("xyz.jpenilla.run-velocity") version "2.3.1"
}
group = plugin_group
version = plugin_version
repositories {
mavenCentral()
maven { url 'https://repo.velocitypowered.com/releases/' }
maven { url 'https://repo.lucko.me/' } // LuckPerms
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.projectlombok:lombok:1.18.24'
compileOnly 'com.velocitypowered:velocity-api:3.2.0-SNAPSHOT'
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
}
shadowJar {
relocate 'com.google.common', 'shadowed.com.google.common'
}
jar {
manifest {
attributes 'Main-Class': 'com.yourname.CrossServerVelocityPlugin'
}
}
processResources{
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks {
runVelocity {
// Configure the Velocity version for our task.
// This is the only required configuration besides applying the plugin.
// Your plugin's jar (or shadowJar if present) will be used automatically.
velocityVersion("3.3.0-SNAPSHOT")
}
}

View File

@ -0,0 +1,2 @@
plugin_group=com.leisuretimedock.crossplugin
plugin_version=1.0.0.0

View File

@ -0,0 +1,70 @@
package com.leisuretimedock.crossplugin;
import com.google.inject.Inject;
import com.leisuretimedock.crossplugin.command.ReloadConfigCommand;
import com.leisuretimedock.crossplugin.handler.PluginChannelHandler;
import com.leisuretimedock.crossplugin.handler.PluginMessageHandler;
import com.leisuretimedock.crossplugin.manager.ConfigManager;
import com.leisuretimedock.crossplugin.messages.I18n;
import com.mojang.brigadier.tree.CommandNode;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.PluginContainer;
import com.velocitypowered.api.plugin.PluginManager;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import org.slf4j.Logger;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Locale;
@Plugin(
id = Static.PLUGIN_ID,
name = Static.PLUGIN_NAME,
version = Static.PLUGIN_VERSION,
authors = "R3944Realms"
)
public class CrossPlugin {
private final ProxyServer server;
public final Logger logger;
public final PluginMessageHandler pluginMessageHandler;
public final PluginChannelHandler pluginChannelHandler;
public static boolean isLuckPermsEnabled;
public final PluginContainer pluginContainer;
@Inject
public CrossPlugin(ProxyServer server, Logger logger, @DataDirectory Path dataDirectory ,PluginContainer pluginContainer) throws IOException {
this.server = server;
this.logger = logger;
ConfigManager config = new ConfigManager(dataDirectory);
I18n.addBundle(Locale.US);
I18n.addBundle(Locale.SIMPLIFIED_CHINESE);
I18n.init();
pluginChannelHandler = new PluginChannelHandler(server, logger, config);
pluginMessageHandler = new PluginMessageHandler(server, logger, config);
this.pluginContainer = pluginContainer;
server.getCommandManager().register(
server.getCommandManager()
.metaBuilder("ltdcs")
.aliases("ltd", "l")
.plugin(pluginContainer)
.build()
,
new ReloadConfigCommand(config)
);
}
@Subscribe
public void onProxyInit(ProxyInitializeEvent event) {
server.getChannelRegistrar().register(PluginMessageHandler.CHANNEL_ID, PluginChannelHandler.CHANNEL_ID);
server.getEventManager().register(this, pluginChannelHandler);
server.getEventManager().register(this, pluginMessageHandler);
isLuckPermsEnabled = server.getPluginManager().getPlugin("luckperms").isPresent();
logger.info("[INIT] Plugin initialized, channel registered.");
}
}

View File

@ -0,0 +1,10 @@
package com.leisuretimedock.crossplugin;
public class Static {
public static final String MOD_ID = "ltdcrossteleport";
public static final String PLUGIN_ID = "ltdcrossserver";
public static final String PLUGIN_NAME = "LTD CrossServer Velocity Plugin";
public static final String PLUGIN_VERSION = "1.0.0.2";
}

View File

@ -0,0 +1,58 @@
package com.leisuretimedock.crossplugin.command;
import com.leisuretimedock.crossplugin.manager.ConfigManager;
import com.leisuretimedock.crossplugin.messages.I18n;
import com.leisuretimedock.crossplugin.messages.I18nKeyEnum;
import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.command.SimpleCommand;
import com.velocitypowered.api.proxy.Player;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
@Slf4j
public class ReloadConfigCommand implements SimpleCommand {
private final ConfigManager configManager;
public static final String PERMISSION_RELOAD = "ltdcrossserver.reload";
public static final String PERMISSION_HELP = "ltdcrossserver.help";
public ReloadConfigCommand(ConfigManager configManager) {
this.configManager = configManager;
}
@Override
public void execute(SimpleCommand.Invocation invocation) {
CommandSource source = invocation.source();
String[] args = invocation.arguments();
// /ltdcrossserver
if (args.length == 0) {
source.sendMessage(I18n.translatable(PERMISSION_HELP, NamedTextColor.YELLOW));
return;
}
String subCommand = args[0].toLowerCase();
switch (subCommand) {
case "reload" -> handleReload(source);
default -> source.sendMessage(I18n.translatable(I18nKeyEnum.UNKNOWN_COMMAND, NamedTextColor.YELLOW));
}
}
private void handleReload(CommandSource source) {
// 控制台允许玩家检查权限
if (source instanceof Player player && !player.hasPermission(PERMISSION_RELOAD)) {
source.sendMessage(I18n.translatable(I18nKeyEnum.NO_PERMISSION_TO_USE_THIS_COMMAND, NamedTextColor.RED, Component.text(PERMISSION_RELOAD)));
return;
}
try {
configManager.reload();
source.sendMessage(I18n.translatable(I18nKeyEnum.RELOAD_CONFIG_SUCCESSFUL, NamedTextColor.GREEN));
} catch (Exception e) {
source.sendMessage(I18n.translatable(I18nKeyEnum.FAILED_TO_RELOAD_CONFIG, NamedTextColor.RED));
log.error("Failed to reload config", e);
}
}
}

View File

@ -0,0 +1,95 @@
package com.leisuretimedock.crossplugin.handler;
import com.leisuretimedock.crossplugin.Static;
import com.leisuretimedock.crossplugin.manager.ConfigManager;
import com.leisuretimedock.crossplugin.manager.OverlayManager;
import com.leisuretimedock.crossplugin.manager.ServerManager;
import com.leisuretimedock.crossplugin.messages.I18n;
import com.leisuretimedock.crossplugin.messages.I18nKeyEnum;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.event.player.ServerConnectedEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
public class PluginChannelHandler {
public static final MinecraftChannelIdentifier CHANNEL_ID =
MinecraftChannelIdentifier.create(Static.MOD_ID, "channel");
private final ProxyServer proxy;
private final Logger logger;
private final ConfigManager configManager;
private final ServerManager serverManager;
private final Set<Player> waitingForReady = Collections.synchronizedSet(new HashSet<>());
public PluginChannelHandler(ProxyServer proxy, Logger logger, ConfigManager configManager) {
this.proxy = proxy;
this.logger = logger;
this.configManager = configManager;
this.serverManager = new ServerManager(proxy);
}
@Subscribe
public void onPluginMessage(PluginMessageEvent event) {
if (!event.getIdentifier().equals(CHANNEL_ID)) return;
if (!(event.getSource() instanceof Player player)) return;
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(event.getData()))) {
String command = in.readUTF();
logger.debug("Received plugin message from {}: {}", player.getUsername(), command);
if (command.startsWith("teleport:")) {
String targetServer = command.substring("teleport:".length());
proxy.getServer(targetServer).ifPresentOrElse(server -> {
player.createConnectionRequest(server).fireAndForget();
logger.debug("Teleporting {} to {}", player.getUsername(), targetServer);
}, () -> {
player.sendMessage(I18n.translatable(I18nKeyEnum.SERVER_NOT_FOUND, NamedTextColor.RED, Component.text(targetServer)));
});
} else if ("client_ready".equals(command)) {
// 收到客户端准备消息
if (waitingForReady.remove(player)) {
logger.debug("[CrossTeleportMod] {} is ready, sending overlay and server list", player.getUsername());
OverlayManager.showOverlay(player);
//TODO未来计划使对应客户端mod可加载来自插件的自定义服务器列表
// OverlayManager.sendServerList(player, serverManager.getAvailableServers());
} else {
logger.debug("[CrossTeleportMod] Received client_ready from {}, but was not waiting", player.getUsername());
}
} else {
logger.warn("[CrossTeleportMod] Unknown plugin command from {}: {}", player.getUsername(), command);
}
} catch (IOException e) {
logger.error("[CrossTeleportMod] Error parsing plugin message", e);
}
}
@Subscribe
public void onPlayerJoin(ServerConnectedEvent event) {
Player player = event.getPlayer();
String currentServer = event.getServer().getServerInfo().getName();
logger.debug("[CrossTeleportMod] Player {} joined server {}", player.getUsername(), currentServer);
if (configManager.getOverlayServers().contains(currentServer)) {
// 标记此玩家等待客户端准备确认
waitingForReady.add(player);
logger.debug("[CrossTeleportMod] Added {} to waitingForReady set", player.getUsername());
} else {
// 不是 lobby隐藏 overlay
OverlayManager.hideOverlay(player);
logger.debug("[CrossTeleportMod] Hide overlay for player {}", player.getUsername());
}
}
}

View File

@ -0,0 +1,85 @@
package com.leisuretimedock.crossplugin.handler;
import com.leisuretimedock.crossplugin.CrossPlugin;
import com.leisuretimedock.crossplugin.Static;
import com.leisuretimedock.crossplugin.manager.ConfigManager;
import com.leisuretimedock.crossplugin.messages.I18n;
import com.leisuretimedock.crossplugin.messages.I18nKeyEnum;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import java.nio.charset.StandardCharsets;
public class PluginMessageHandler {
public static final MinecraftChannelIdentifier CHANNEL_ID =
MinecraftChannelIdentifier.create(Static.MOD_ID, "teleport");
private static final String PERMISSION_HEAD = Static.MOD_ID + ".goto.";
private final ProxyServer server;
private final Logger logger;
private final ConfigManager config;
public PluginMessageHandler(ProxyServer server, Logger logger, ConfigManager config) {
this.server = server;
this.logger = logger;
this.config = config;
}
@Subscribe
public void onPluginMessage(PluginMessageEvent event) {
if (!(event.getSource() instanceof Player player)) return;
if (!event.getIdentifier().equals(CHANNEL_ID)) return;
byte[] data = event.getData();
String raw = new String(data, 1, data.length - 1, StandardCharsets.UTF_8);
logger.info("Received plugin message from {}: {}", player.getUsername(), raw);
// 处理 connect:key 模式
if (raw.startsWith("connect:")) {
String key = raw.substring("connect:".length());
String targetServerName = config.resolveServerName(key);
if (isAlreadyOnServer(player, targetServerName)) {
player.sendMessage(I18n.translatable(I18nKeyEnum.ALREADY_ON_SERVER, NamedTextColor.RED));
return;
}
server.getServer(targetServerName).ifPresentOrElse(
srv -> player.createConnectionRequest(srv).fireAndForget(),
() -> player.sendMessage(I18n.translatable(I18nKeyEnum.SERVER_NOT_FOUND, NamedTextColor.RED, Component.text(targetServerName)))
);
return;
}
// 普通 serverName 模式
String permissionNode = PERMISSION_HEAD + raw;
//这个权限是 "ltdcrossteleport.goto.<xx服务器名>"
if (CrossPlugin.isLuckPermsEnabled && !player.hasPermission(permissionNode)) {
player.sendMessage(I18n.translatable(I18nKeyEnum.NO_PERMISSION_TO_TRANS_THIS_SERVER, NamedTextColor.RED, Component.text(raw)));
return;
}
if (isAlreadyOnServer(player, raw)) {
player.sendMessage(I18n.translatable(I18nKeyEnum.ALREADY_ON_SERVER, NamedTextColor.RED));
return;
}
server.getServer(raw).ifPresentOrElse(
srv -> player.createConnectionRequest(srv).fireAndForget(),
() -> player.sendMessage(I18n.translatable(I18nKeyEnum.SERVER_NOT_FOUND, NamedTextColor.RED, Component.text(raw)))
);
}
private boolean isAlreadyOnServer(Player player, String serverName) {
return player.getCurrentServer()
.map(current -> current.getServerInfo().getName().equalsIgnoreCase(serverName))
.orElse(false);
}
}

View File

@ -0,0 +1,150 @@
package com.leisuretimedock.crossplugin.manager;
import lombok.extern.slf4j.Slf4j;
import org.spongepowered.configurate.ConfigurationNode;
import org.spongepowered.configurate.yaml.YamlConfigurationLoader;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@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();
private ConfigurationNode rootNode;
public ConfigManager(Path configDir) throws IOException {
this.configPath = configDir.resolve("config.yml");
copyDefaultConfigIfAbsent(this.configPath);
this.loader = YamlConfigurationLoader.builder()
.path(configPath)
.indent(2)
.build();
load();
}
/**
* Loads or creates the configuration file
* @throws IOException if configuration cannot be loaded
*/
public synchronized void load() throws IOException {
try {
rootNode = loader.load();
// Load server aliases
ConfigurationNode aliasesNode = rootNode.node("server-aliases");
if (aliasesNode.virtual() || aliasesNode.empty()) {
aliasesNode.node("survival").set("survival");
aliasesNode.node("lobby").set("lobby");
loader.save(rootNode);
}
serverAliases.clear();
aliasesNode.childrenMap().forEach((key, node) -> {
String realName = node.getString();
if (realName != null) serverAliases.put(key.toString(), realName);
});
// Load overlay servers
ConfigurationNode overlayNode = rootNode.node("show-overlay-servers");
if (overlayNode.virtual() || overlayNode.empty()) {
overlayNode.setList(String.class, List.of("lobby"));
loader.save(rootNode);
}
overlayServers.clear();
for (ConfigurationNode node : overlayNode.childrenList()) {
String name = node.getString();
if (name != null) overlayServers.add(name.toLowerCase());
}
log.info("Loaded {} server aliases from config", serverAliases.size());
log.info("Loaded {} overlay servers from config", overlayServers.size());
} catch (IOException e) {
log.error("Failed to load configuration from {}", configPath, e);
throw e;
}
}
/**
* Reloads the configuration from disk
*/
public synchronized void reload() throws IOException {
load();
}
/**
* Saves the current configuration to disk
*/
public synchronized void save() throws IOException {
loader.save(rootNode);
}
/**
* Resolves a server name from its alias
* @param key The alias or real server name to resolve
* @return The real server name, or the input if no alias exists
*/
public String resolveServerName(String key) {
return serverAliases.getOrDefault(key, key);
}
/**
* Gets all server aliases as an unmodifiable map
*/
public Map<String, String> getServerAliases() {
return Collections.unmodifiableMap(serverAliases);
}
/**
* Adds or updates a server alias
* @param alias The alias to add/update
* @param realName The real server name
*/
public synchronized void setServerAlias(String alias, String realName) throws IOException {
rootNode.node("server-aliases", alias).set(realName);
serverAliases.put(alias, realName);
save();
}
/**
* Removes a server alias
* @param alias The alias to remove
*/
public synchronized void removeServerAlias(String alias) throws IOException {
rootNode.node("server-aliases").removeChild(alias);
serverAliases.remove(alias);
save();
}
/**
* Servers should show overlay
* @return lists Show OverLay when join they
*/
public Set<String> getOverlayServers() {
return Collections.unmodifiableSet(overlayServers);
}
private void copyDefaultConfigIfAbsent(Path configPath) throws IOException {
if (Files.notExists(configPath)) {
// 先创建父目录如果不存在
Files.createDirectories(configPath.getParent());
try (InputStream in = getClass().getClassLoader().getResourceAsStream("config.yml")) {
if (in == null) {
throw new IOException("Missing embedded config.yml in resources!");
}
Files.copy(in, configPath);
log.info("Default config.yml copied to {}", configPath);
}
}
}
}

View File

@ -0,0 +1,67 @@
package com.leisuretimedock.crossplugin.manager;
import com.leisuretimedock.crossplugin.handler.PluginChannelHandler;
import com.leisuretimedock.crossplugin.messages.I18n;
import com.leisuretimedock.crossplugin.messages.I18nKeyEnum;
import com.velocitypowered.api.proxy.Player;
import net.kyori.adventure.text.format.NamedTextColor;
import java.util.ArrayList;
import java.util.List;
public class OverlayManager {
public static void showOverlay(Player player) {
sendRawCommand(player, "overlay:show");
}
public static void hideOverlay(Player player) {
sendRawCommand(player, "overlay:hide");
}
private static void sendRawCommand(Player player, String command) {
if (!player.isActive()) return;
try (var out = new java.io.ByteArrayOutputStream();
var data = new java.io.DataOutputStream(out)) {
data.writeUTF(command); // 这里写入字符串包括长度前缀
data.flush();
player.sendPluginMessage(
PluginChannelHandler.CHANNEL_ID,
out.toByteArray()
);
} catch (Exception e) {
// 处理异常日志等等
throw new RuntimeException(e);
}
}
//TODO : 客户端模组未来待实现
public static void sendServerList(Player player, Iterable<ServerManager.ServerInfo> servers) {
try (var out = new java.io.ByteArrayOutputStream();
var data = new java.io.DataOutputStream(out)) {
data.writeUTF("gui:server_list");
List<ServerManager.ServerInfo> list = new ArrayList<>();
servers.forEach(list::add);
data.writeInt(list.size());
for (var server : list) {
data.writeUTF(server.id()); // 名称
data.writeUTF(server.motd()); // MOTD
data.writeUTF(server.id()); // 目标 ID
}
player.sendPluginMessage(
PluginChannelHandler.CHANNEL_ID,
out.toByteArray()
);
} catch (Exception e) {
player.sendMessage(I18n.translatable(I18nKeyEnum.FAILED_TO_SEND_SERVER_LIST, NamedTextColor.RED));
}
}
}

View File

@ -0,0 +1,37 @@
package com.leisuretimedock.crossplugin.manager;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
//TODO 未来计划
public class ServerManager {
private final ProxyServer proxy;
private final Map<String, ServerInfo> serverMap = new HashMap<>();
public ServerManager(ProxyServer proxy) {
this.proxy = proxy;
// 示例静态初始化可跳转服务器
registerServer("lobby", "大厅服务器");
registerServer("survival", "生存服务器");
}
private void registerServer(String id, String motd) {
Optional<RegisteredServer> server = proxy.getServer(id);
server.ifPresent(s -> serverMap.put(id, new ServerInfo(id, motd)));
}
public Collection<ServerInfo> getAvailableServers() {
return serverMap.values();
}
public Optional<RegisteredServer> getServerById(String id) {
return proxy.getServer(id);
}
public record ServerInfo(String id, String motd) {}
}

View File

@ -0,0 +1,107 @@
package com.leisuretimedock.crossplugin.messages;
import com.leisuretimedock.crossplugin.Static;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentLike;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.Style;
import net.kyori.adventure.translation.GlobalTranslator;
import net.kyori.adventure.translation.TranslationRegistry;
import net.kyori.adventure.util.UTF8ResourceBundleControl;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle;
@Slf4j
public class I18n {
public static TranslationRegistry registry = TranslationRegistry.create(Key.key(Static.MOD_ID +":value"));
public static HashMap<Locale,ResourceBundle> bundles = new HashMap<>();
public static void init() {
for (Map.Entry<Locale, ResourceBundle> bundle : bundles.entrySet()) {
registry.registerAll(bundle.getKey(), bundle.getValue(), true);
}
GlobalTranslator.translator().addSource(registry);
}
public static ResourceBundle getBundle(Locale locale) {
return ResourceBundle.getBundle("crossserver.Bundle."+ locale.toLanguageTag().replace('-','_'), locale, UTF8ResourceBundleControl.get());
}
public static void addBundle(Locale locale) {
if (bundles.containsKey(locale)) {
log.warn("Duplicate bundle locale: {}", locale);
}
else bundles.put(locale, getBundle(locale));
}
// 基础无颜色版本
public static Component translatable(String key, ComponentLike... args) {
return Component.translatable(key, args);
}
public static Component translatable(String key) {
return Component.translatable(key);
}
public static Component translatable(String key, String fallback, ComponentLike... args) {
return Component.translatable(key, fallback, args);
}
public static Component translatable(String key, String fallback) {
return Component.translatable(key, fallback);
}
public static Component translatable(I18nKeyEnum key, ComponentLike... args) {
return Component.translatable(key.getKey(), args);
}
public static Component translatable(I18nKeyEnum key) {
return Component.translatable(key.getKey());
}
public static Component translatable(I18nKeyEnum key, String fallback, ComponentLike... args) {
return Component.translatable(key.getKey(), fallback, args);
}
public static Component translatable(I18nKeyEnum key, String fallback) {
return Component.translatable(key.getKey(), fallback);
}
// 下面是带颜色或样式版本核心是先创建基础 Component再调用 color style
public static Component translatable(String key, NamedTextColor color, ComponentLike... args) {
return Component.translatable(key, args).color(color);
}
public static Component translatable(String key, Style style, ComponentLike... args) {
return Component.translatable(key, args).style(style);
}
public static Component translatable(I18nKeyEnum key, NamedTextColor color, ComponentLike... args) {
return Component.translatable(key.getKey(), args).color(color);
}
public static Component translatable(I18nKeyEnum key, Style style, ComponentLike... args) {
return Component.translatable(key.getKey(), args).style(style);
}
// fallback 的带颜色或样式版本
public static Component translatable(String key, String fallback, NamedTextColor color, ComponentLike... args) {
return Component.translatable(key, fallback, args).color(color);
}
public static Component translatable(String key, String fallback, Style style, ComponentLike... args) {
return Component.translatable(key, fallback, args).style(style);
}
public static Component translatable(I18nKeyEnum key, String fallback, NamedTextColor color, ComponentLike... args) {
return Component.translatable(key.getKey(), fallback, args).color(color);
}
public static Component translatable(I18nKeyEnum key, String fallback, Style style, ComponentLike... args) {
return Component.translatable(key.getKey(), fallback, args).style(style);
}
}

View File

@ -0,0 +1,22 @@
package com.leisuretimedock.crossplugin.messages;
import lombok.Getter;
@Getter
public enum I18nKeyEnum {
NO_PERMISSION_TO_TRANS_THIS_SERVER("ltd.plugin.trans.failed.no_permission"),
NO_PERMISSION_TO_USE_THIS_COMMAND("ltd.plugin.command.no_permission"),
SERVER_NOT_FOUND("ltd.plugin.trans.failed.server_not_found"),
ALREADY_ON_SERVER("ltd.plugin.trans.failed.already_on_server"),
FAILED_TO_SEND_SERVER_LIST("ltd.plugin.send_server_list.failed"),
FAILED_TO_RELOAD_CONFIG("ltd.plugin.reload.failed.error"),
RELOAD_CONFIG_SUCCESSFUL("ltd.plugin.reload.successful"),
COMMAND_HELP("ltd.plugin.help.command"),
UNKNOWN_COMMAND("ltd.plugin.command.unknown_command"),
;
final String key;
I18nKeyEnum(String key) {
this.key = key;
}
}

View File

@ -0,0 +1,21 @@
# LTD CrossServer Plugin Configuration
# 闲趣时坞跨服传送插件配置文件
# ----------------------------------------
# Server Aliases / 服务器别名映射
# 玩家使用 /goto 命令时可以输入别名
# Players can use these aliases with /goto
# 格式: alias: real-server-name
# Format: 别名: 实际服务器名称
server-aliases:
survival: survival # 生存服 / Survival server
lobby: lobby # 大厅服 / Lobby server
# ----------------------------------------
# Show Overlay Servers / 显示 Overlay 的服务器列表
# 玩家加入这些服务器时客户端将显示 Overlay 提示
# Clients will show overlay tips when joining these servers
# 你可以添加多个服务器名
# You can add multiple server names
show-overlay-servers:
- lobby

View File

@ -0,0 +1,9 @@
ltd.plugin.trans.failed.no_permission=You don't have permission to teleport to this server ({0})!
ltd.plugin.trans.failed.server_not_found=The target server ({0}) was not found!
ltd.plugin.trans.failed.already_on_server=You have already been in that server.
ltd.plugin.send_server_list.failed=Failed to send server list.
ltd.plugin.reload.successful=Config is reloaded.
ltd.plugin.reload.failed.error=Failed to reload config, please check the server logs.
ltd.plugin.command.no_permission=You don't have permission to execute this command.
ltd.plugin.help.command=Usage: /ltdsc reload.
ltd.plugin.command.unknown_command=Unknown sub command: {0}

View File

@ -0,0 +1,10 @@
ltd.plugin.trans.no_permission=你没有权限传送到该服务器!({0})
ltd.plugin.trans.server_not_found=目标服务器不存在!({0})
ltd.plugin.trans.already_on_server=你已经在该服务器上了。
ltd.plugin.send_server_list.failed=发送服务器列表失败。
ltd.plugin.command.no_permission=你没有权限重载去执行该指令,需要权限节点:{0}
ltd.plugin.reload.successful=配置已重新加载。
ltd.plugin.reload.failed.error=重新加载配置时出错,请查看控制台日志。
ltd.plugin.help.command=用法: /ltdsc reload.
ltd.plugin.command.unknown_command=未知的子命令: {0}