Development
August 24, 2018

SidedProxy: все, что вы хотели узнать о клиент-серверной архитектуре, но боялись спросить

Почему мультиплеер - это сложно

Minecraft умеет в мультиплеер. Если бы это, в него бы, вероятно, не играли. И, если бы не мультиплеер, кода в Minecraft'е было бы порядком меньше.

Если говорить о сетевом коде, его задача - постоянно синхронизировать данные между игроками. Мир, погода, поведение мобов, траектория проджектайлов - у всех все должно быть одинаковым. Это не такая простая архитектурная задача: с одной стороны, нам нужно выполнять всю "общую" логику на стороне сервера, с другой - эта же логика должна быть в синглплеере, где сервера как такого нет. И все это должно быть на основе одного и того же кода, единой системой. Как же это сделать?

Разделяй и властвуй: как усложнить логику, но упростить жизнь

В Minecraft'е не существует синглплеера. Совсем. Это ложь, фикция и вообще они пошутили. Для того, чтобы одна логика обрабатывала и одиночный, и сетевой режимы, каждый раз, когда мы запускаем синглплеер, система в фоне запускает сервер, на котором мы играем одни.

Поэтому когда говорят о сервере и клиенте, речь не про расположение выполняемого кода (на ПК или каком-то, в т.ч. локальном, хостинге), а о том, какая логика выполняется: серверная (промежуточные расчеты, которые не нужны игроку), клиентская (графика, обработка клавиш, звуки) или общая, исполняемая синхронно и там, и там.

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

Как же с этим работать?

Когда мы пишем мод, он выполняется и на сервере, и на клиенте. В общем случае код исполняется и там, и там. Но есть ситуации, когда мы хотим выполнить что-то только на одной стороне, будь то, к примеру, генерация случайного ключа на сервере или обработка нажатия клавиши на клиенте. В подобных ситуациях необходимо контролировать, на какой стороне будет выполнятся код.

Forge позволяет проверять, где проходит исполнение, c помощью функций world.isRemote() для мира и event.getSide()==Side.SERVER для события, возвращающих true на сервере на false на клиенте.

Для того, чтобы код было легче поддерживать и отлаживать, принято разделять логику на отдельные файлы-proxy. ClientProxy и ServerProxy выполняют специфическую логику, CommonProxy же общую. Как и сам мод, proxy должны содержать три функции: preInit(), init() и postInit(), которые будут выполняется на соответствующем этапе инициализации.


Решение "в лоб": пишем ветвление сами

Для того, чтобы лучше понять, как это работает, посмотрим на простейшую реализацию такой системы.

Любой прокси:

public class ...Proxy
{
    public void preInit()
    {
        //код
    }

    public void init()
    {
        //код
    }

    public void postInit()
    {
        //код
    }
}

ExampleMod.java:

@Mod(modid = "example", version = "1")
public class ExampleMod
{
    CommonProxy commonProxy = new CommonProxy();
    ServerProxy serverProxy = new ServerProxy();
    ClientProxy clientProxy = new clientProxy();
    
    
    @Mod.EventHandler
    public void preInit(FMLPreInitializationEvent event)
    {
        commonProxy.preInit();
        
        if(event.getSide()==Side.CLIENT)
            clientProxy.preInit();
        else
            serverProxy.preInit();
    }

    @Mod.EventHandler
    public void init(FMLInitializationEvent event)
    {
        commonProxy.init();
        
        if(event.getSide()==Side.CLIENT)
            clientProxy.init();
        else
            serverProxy.init();
    }

    @Mod.EventHandler
    public void postInit(FMLPostInitializationEvent event)
    {
        commonProxy.postInit();
        
        if(event.getSide()==Side.CLIENT)
            clientProxy.postInit();
        else
            serverProxy.postInit();
    }
}

Общий код выполняется во всех случаях, зависящий от стороны - только на своей стороне.

Решение "в лоб" 2.0: добавляем наследование

Решение, приведенное выше, является неоптимальным. Для того, чтобы упростить, воспользуемся ООП: пусть ClientProxy и ServerProxy наследуются от CommonProxy, запуская при этом родительский код.

CommonProxy.java:

public class CommonProxy
{
    public void preInit()
    {
        //общий код
    }

    public void init()
    {
        //общий код
    }

    public void postInit()
    {
        //общий код
    }
}

Любая из сторон:

public class ...Proxy extends CommonProxy
{

    public void preInit()
    {
        super.preInit();
        //Специфический код
    }


    public void init()
    {
        super.init();
        //Специфический код
    }


    public void postInit()
    {
        super.postInit();
        //Специфический код
    }
}

В основном моде будем хранить только один прокси, а какой - решать один раз на этапе преинициализации.

ExampleMod.java:

@Mod(modid = "example", version = "1")
public class ExampleMod
{
    CommonProxy proxy;
    
    @Mod.EventHandler
    public void preInit(FMLPreInitializationEvent event)
    {
        if(event.getSide()==Side.CLIENT)
            proxy = new ClientProxy;
        else
            proxy = new serverProxy;
        
        proxy.preInit();

    }

    @Mod.EventHandler
    public void init(FMLInitializationEvent event)
    {
        proxy.init();
    }

    @Mod.EventHandler
    public void postInit(FMLPostInitializationEvent event)
    {
        proxy.postInit();
    }
}

Решение в одну строку: все, что вы хотели знать об аннотации @Mod, но боялись спросить

Наше второе решение имеет право на жизнь. Однако прокси сторон - настолько часто используемый шаблон, что разработчики Forge встроили в него готовое решение. Для того, чтобы им воспользоваться, нам не придется менять ничего в самих прокси из второго варианта, но нужно будет сократить код в основном файле.

ExampleMod.java

@Mod(modid = "example", version = "1")
public class ExampleMod
{
    @SidedProxy(clientSide = "com.example.examplemod.ClientProxy", serverSide = "com.example.examplemod.ServerProxy")
    static CommonProxy proxy;
    
    @Mod.EventHandler
    public void preInit(FMLPreInitializationEvent event)
    {
        proxy.preInit();
    }

    @Mod.EventHandler
    public void init(FMLInitializationEvent event)
    {
        proxy.init();
    }

    @Mod.EventHandler
    public void postInit(FMLPostInitializationEvent event)
    {
        proxy.postInit();
    }
}

Аннотация @SidedProxy(), принимающая в качестве параметров пакеты-стороны, сама запишет в переменную proxy тот прокси, который будет соответствовать потоку исполнения и сама его инициализирует.

Код стал максимально коротким и информативным, а логика разделена на три составляющие. Для того, чтобы использовать все возможности разделения серверной и клиентской логики, необходимо также научиться слать сообщения между потоками, но это уже другая история...