跳到主要内容

服务端和客户端

有关服务端和客户端的内容十分重要,值得我们在绪论之后单独开一节来讲。


如果你曾经自己开过服务器来玩,你应该就知道服务端和客户端是不同的。多人游戏时,物理服务端和物理客户端运行在不同的物理主机上,通过网络传输信息。而即使在单人游戏中,MC也存在一个逻辑服务端和一个逻辑客户端。

显然,有些数据需要在正确的一端进行运算,并同步至另外一端(幽灵方块就是个典型的反面例子);有些数据只在一端存在(比如粒子效果)。单人游戏时两端在不同线程上运行,多人游戏时两端在不同设备上运行,显然这需要数据同步、更新才能保证程序的准确运行。

你是哪端的?

有一些方法在两端都会被执行,而一些时候我们希望在不同侧执行不同的逻辑,这就需要分辨。而通常参数中都会有Level

public void bothSideMethod(Level level){
if (level.isClientSide){
//客户端逻辑
} else {
//服务端逻辑
}
}

借助level.isClientSide,我们可以判断传入的参数是对应逻辑服务端还是逻辑客户端。

获取服务端和客户端

在多数时候,方法的形参带有level,我们可以直接使用上面的方法来实现想要的逻辑。但偶尔我们需要“凭空”获取两端。

在客户端,要想获取客户端十分简单:

Minecraft minecraft = Minecraft.getInstance();
//如果你还想要Level
ClientLevel clientLevel = minecraft.level;

在服务端,你需要借助LogicalSidedProvider

MinecraftServer minecraftServer = (MinecraftServer) LogicalSidedProvider.WORKQUEUE.get(LogicalSide.SERVER);
//如果你还想要Level,由于有多个Level存在,你需要提前通过其他方法获取ResourceKey<Level>
//原版三个世界的ResourceKey在Level类中可以找到,以主世界为例:
ServerLevel serverLevel = minecraftServer.getLevel(Level.OVERWORLD);
//当然,你可以遍历所有的level,通过某种条件来找到自己想要的。但请尽量不要这样做,除非真的很有必要,没有其他方法,或者你已经尽了最大可能减少性能开销。

当然你也可以使用LogicalSidedProvider来获取客户端,但是在多数时候显然不如直接使用Minecraft.getInstance()来的方便。

你也可以使用DistExecutor来分辨两端并执行逻辑,但上述的方法在绝大多数时候已经足够了。

OnlyIn!

如果你因为好奇而查看了ClientLevel的具体内容,你可能已经注意到了这样一个注解@OnlyIn(Dist.CLIENT)。括号中的value一般是Dist.CLIENT,极少数时候会用到Dist.DEDICATED_SERVER。字段、方法等一旦被@OnlyIn注解,他们对于另一端就是逻辑上不可见的(即使这几行代码确实存在于磁盘之中)。请注意,@OnlyIn区分的是物理两端而不是逻辑两端。这意味着单人游戏中的服务端可以执行被注解@OnlyIn(Dist.CLIENT)的代码,而@OnlyIn(Dist.DEDICATED_SERVER)在单人游戏时意味着谁也不能执行到它。

对于被@OnlyIn标记的类,需要注意的是,即使你已经用了isClientSide来区分,你也不能在一个双端方法内使用它(的对象),需要单独拆出来:

//这是一个错误的用法
public void bothSideMethodWrong(Level level){
if (level.isClientSide){
Minecraft minecraft = Minecraft.getInstance();
//客户端逻辑
} else {
MinecraftServer minecraftServer = (MinecraftServer) LogicalSidedProvider.WORKQUEUE.get(LogicalSide.SERVER);
//服务端逻辑
}
}

//这是一个正确的用法
public void bothSideMethodCorrect(Level level){
if (level.isClientSide){
doClientStuff();
} else {
doServerStuff();
}
}

private void doClientStuff(){
Minecraft minecraft = Minecraft.getInstance();
//客户端逻辑
}

private void doServerStuff(){
MinecraftServer minecraftServer = (MinecraftServer) LogicalSidedProvider.WORKQUEUE.get(LogicalSide.SERVER);
//服务端逻辑
}

在你第一次启动runServer时,你很有可能遇到这样的报错:

Caused by: java.lang.BootstrapMethodError: java.lang.RuntimeException: Attempted to load class net/minecraft/client/resources/sounds/SoundInstance for invalid dist DEDICATED_SERVER

你可能会感到很疑惑:我明明已经在我的实体/物品/方块类中像上面说的一样,把两端方法分开写了啊?

请检查你的类成员变量中是否像这样,有单端对象:

//来自之前版本的Extinguish, 由于过时而采用BSD-3
public abstract class AbstractFireExtinguisher extends Item {
private final int maxTime;
private static final Predicate<Entity> ALL_BUT_SPECTATOR = entity -> !entity.isSpectator();
private static final double INTERACT_DISTANCE = 7;
FollowingSoundInstance soundInstanceBuffer = null;
...

其中的FollowingSoundInstance继承自SimpleSoundInstance,而SimpleSoundInstance显然有着@OnlyIn的注解。

如此的话,你需要在你的单端方法上也加上@OnlyIn的注解(成员变量的定义处也加上):

...
@OnlyIn(Dist.CLIENT)
FollowingSoundInstance soundInstanceBuffer = null;
...
//来自之前版本的Extinguish, 由于过时而采用BSD-3
@OnlyIn(Dist.CLIENT)
public void stopClientSound() {
if (soundInstanceBuffer != null) {
Minecraft.getInstance().getSoundManager().stop(soundInstanceBuffer);
soundInstanceBuffer = null;
}
}

一些争议

一些开发者声称,“没有任何理由在mod中使用@OnlyIn。”他们会建议你将类或方法分离出去,写到一个单独的包或者类中,从而完全规避使用它。

我的观点是:

  • 你应该仅出于规避ClassNotFoundException或者BootstrapMethodError之类,由原版使用了@OnlyIn,但你没有专门处理而产生的错误。是的,这种情况也可以通过上述的分离来解决,但大多数适合是不划算的——我可能只是需要一行Minecraft.getInstance()...;,而不是什么复杂的逻辑。而当你的逻辑达到一定长度时,你就更可能需要将他们拆分出去——但总之这是你的自由。
  • 但另一种情况是,你编写了某个类或者方法,想(在以后)提醒你自己或其他开发者只能在客户端使用他们。这样的话,你更应该在你的包、类或方法名称里加上Client字样来区分——而不是使用@OnlyIn

综上,我只推荐你被动地使用@OnlyIn,而不应该主动地使用它——鬼知道以后会在哪里给你搞出ClassNotFoundException

数据同步

显然我们需要时不时地把服务端的变动同步到客户端,把客户端的输入同步到服务端,不然两边都不知道对面在干嘛。

好消息是,MC已经给我们造好了一大堆轮子可以直接使用。坏消息是,有些时候这些原版提供的轮子并不够用,需要我们手动发包同步。不同类有不同的方法来进行同步,而最通用的方式是手动发包,我将会在5 网络包一节讲解。其他方式的详细内容会在涉及到的时候再讲。