Как стать автором
Обновить

Эволюция игрового фреймворка. Клиент 3. Слои логики

Время на прочтение14 мин
Количество просмотров2.4K

Прежде мы рассмотрели отделение логики отображения от графики, а также разные вспомогательные классы и менеджеры. Все вместе они образуют каркас наших приложений и были вынесены в отдельную библиотеку — Core Framework. Осталось еще разработать методику по написанию остальной логики. В нее входит бизнес-логики и правила игры, данные и их обработка, а также взаимодействие с сервером.

Вся логика будет разбита на слои. Основной смысл слоев тот, что классы одного слоя максимально независимы от классов с соседних слоев и абсолютно независимы от остальных. Все это уже относится не к основному фреймворку (Core Framework), а к фреймворкам для разных групп жанров (Base Game Frameworks) и для каждого отдельного жанра (Game Frameworks).

Получается следующая иерархия библиотек:

  • Core Framework

  • Base Game Frameworks (использует классы из Core Framework)

  • Game Frameworks (использует классы из Core и Base Game Frameworks)

Отделение бизнес-логики

Пока что у нас вся логика находилась в компонентах (Component), что означает, что мы до сей поры не делали разделения между логикой отображения (view logic) и логикой предметной области (domain logic), или бизнес-логикой (business logic). То есть и логика отображения и бизнес-логика реализуются в одном и том же классе. Это еще ничего, если проектом занимается один человек, но если их несколько, то разделять полномочия в команде становится непросто. Да и сам класс может стать большим и запутанным.

Однако, мы разделяем разные виды логики не из простой любви к порядку. Очень затруднительно использовать с одной и той же бизнес-логикой разные логики отображения (и наоборот), если они слиты в одном классе. Также нельзя развивать их отдельно друг от друга: если мы наследуемся от класса для усложнения логики отображения, мы автоматически тащим за собой и логику отображения, ведь все они реализованы в одном классе. Также их нельзя и применять по отдельности. Например, если бизнес-логика реализована на сервере, а нам в клиенте нужна только логика отображения.

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

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

Модель

Программа — это отражение реальности. К сожалению мы пока еще не можем повторить реальность во всей ее полноте. Да это и не нужно. Для решения конкретных задач нам достаточно отразить лишь основные, существенные ее стороны и отношения. То есть построить модель реальности. В нашем случае модель представлена двумя аспектами: состоянием и действиями. Состояние — это данные, хранящиеся в переменных, то есть в памяти, а действия — инструкции, выполняющиеся процессором. Инструкции процессора слишком примитивны, поэтому мы в качестве минимальных единиц действия будем брать функции.

Те классы, которые содержат данные и функции по их непосредственной обработке и ничего больше, мы будем называть моделями. В нашей игре класс модели состоит из конфигурации выбранных номеров одежек каждого типа (массив целых чисел state) и функций, меняющих отдельную одежку (changeItem()) или всех их вместе (set_state()):

class DresserModel
{
    // State
    public var state(default, set):Array<Int> = [];
    public function set_state(value:Array<Int>):Array<Int>
    {
        if (value == null)
        {
            value = [];
        }
        if (!ArrayUtil.equal(state, value))
        {
            state = value;
            stateChangeSignal.dispatch(value);
        }
        return value;
    }
    public var stateChangeSignal(default, null) = new Signal<Array<Int>>();
    public var itemChangeSignal(default, null) = new Signal2<Int, Int>();

    public function changeItem(index:Int, value:Int):Void
    {
        if (state[index] != value)
        {
            state[index] = value;
            itemChangeSignal.dispatch(index, value);
        }
    }
}

Основной (и единственный) игровой компонент Dresser с моделью будет выглядеть немного массивнее, чем раньше:

class Dresser extends Component
{
    // Settings
    public var itemPathPrefix = "item";
    // State
    private var model:DresserModel;
    private var items:Array<MovieClip>;

		// Init
    override private function init():Void
    {
        super.init();
        model = ioc.create(DresserModel);
        model.stateChangeSignal.add(model_stateChangeSignalHandler);
        model.itemChangeSignal.add(model_itemChangeSignalHandler);
    }
    override public function dispose():Void
    {
        if (model != null)
        {
            model.stateChangeSignal.remove(model_stateChangeSignalHandler);
            model.itemChangeSignal.remove(model_itemChangeSignalHandler);
            model = null;
        }
        super.dispose();
    }
		// Methods
    override private function assignSkin():Void
    {
        super.assignSkin();

        var itemPaths = cast resolveSkinPathPrefix(itemPathPrefix);
        items = [for (path in itemPaths) resolveSkinPath(path)];
        for (item in items)
        {
            item.stop();
            item.buttonMode = true;
            item.addEventListener(MouseEvent.CLICK, item_clickHandler);
        }
        // Apply
        refreshState();
    }
    override private function unassignSkin():Void
    {
        items = null;
        super.unassignSkin();
    }
    private function switchItem(index:Int, step:Int=1):Void
    {
        var item = items[index];
        if (item != null)
        {
            var value = (item.currentFrame + step) % item.totalFrames;
            value = value < 1 ? item.totalFrames - value : value;
            model.changeItem(index, value);
        }
    }
    private function refreshState():Void
    {
         for (i => v in model.state)
         {
             var item = items[i];
             if (item != null)
             {
                 item.gotoAndStop(v);
             }
         }
    }
    // Handlers
    private function item_clickHandler(event:MouseEvent):Void
    {
        var item:MovieClip = Std.downcast(event.currentTarget, MovieClip);
        var index = items.indexOf(item);
        switchItem(index, 1);
    }
    private function model_stateChangeSignalHandler(value:Array<Int>):Void
    {
        refreshState();
    }
    private function model_itemChangeSignalHandler(index:Int, value:Int):Void
    {
        var item = items[index];
        if (item != null)
        {
            item.gotoAndStop(value);
        }
    }
}

Отделение сервиса

Пока что непонятно, зачем так все усложнять. Но стоит добавить в список требований необходимость поддержки сервера, и оправданность данного решения становится очевидной. Мы можем реализовать вторую модель с поддержкой сетевого режима, и при этом у нас останется также первая ее версия — для standalone-режима. В компонент мы можем подставлять любую по желанию, так как интерфейс у них один, и для Dresser они неотличимы.

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

class DresserModel
{
    // State
    private var service:DresserService;
    private var _state:Array<Int> = [];
    @:isVar
    public var state(get, set):Array<Int>;
    public function get_state():Array<Int>
    {
        return _state;
    }
    public function set_state(value:Array<Int>):Array<Int>
    {
        if (value == null)
        {
            value = [];
        }
        if (!ArrayUtil.equal(_state, value))
        {
            service.setState(value);
        }
        return value;
    }
    // Signals
    public var stateChangeSignal(default, null) = new Signal<Array<Int>>();
    public var itemChangeSignal(default, null) = new Signal2<Int, Int>();

		// Init
    public function new()
    {
        service = new DresserService();
        service.loadSignal.add(service_loadSignalHandler);
        service.stateChangeSignal.add(service_stateChangeSignalHandler);
        service.itemChangeSignal.add(service_itemChangeSignalHandler);
    }
    // Methods
    public function load():Void
    {
        service.load();
    }
    public function changeItem(index:Int, value:Int):Void
    {
        if (state[index] != value)
        {
            service.changeItem(index, value);
        }
    }
    // Handlers
    private function service_loadSignalHandler(value:Dynamic):Void
    {
        _state = cast value;
        stateChangeSignal.dispatch(value);
    }
    private function service_stateChangeSignalHandler(value:Array<Int>):Void
    {
        _state = cast value;
        stateChangeSignal.dispatch(value);
    }
    private function service_itemChangeSignalHandler(index:Int, value:Int):Void
    {
        state[index] = value;
        itemChangeSignal.dispatch(index, value);
    }
}
class DresserService
{
    // Settings
    public var url = "http://127.0.0.1:5000/storage/dresser";
    // State
    public var loadSignal(default, null) = new Signal<Array<Int>>();
    public var stateChangeSignal(default, null) = new Signal<Array<Int>>();
    public var itemChangeSignal(default, null) = new Signal2<Int, Int>();

    public function load():Void
    {
        new Request().send(url, null, function (response:Dynamic):Void {
            var data = parseResponse(response);
            if (data.success)
            {
                loadSignal.dispatch(data);
            }
        });
    }
    public function setState(value:Array<Int>):Array<Int>
    {
        new Request().send(url, value, function (response:Dynamic):Void {
            var data = parseResponse(response);
            if (data.success)
            {
                stateChangeSignal.dispatch(data);
            }
        }, URLRequestMethod.POST);
    }
    public function changeItem(index:Int, value:Int):Void
    {
        if (state[index] != value)
        {
            new Request().send(url, {index: index, value: value}, function (response:Dynamic):Void {
                var data = parseResponse(response);
                if (data.success)
                {
                    itemChangeSignal.dispatch(index, value);
                }
            }, "PATCH");
        }
    }
    private function parseResponse(response:Dynamic):Dynamic
    {
        Log.debug('Load data: ${response} from url: $url');
        try
        {
            var data:Dynamic = Json.parse(response);
            Log.debug(' Loaded state data: ${data} from url: $url');
            return data;
        }
        catch (e:Exception)
        {
            Log.error('Parsing error: $e');
        }
        return null;
    }
}

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

Отделение транспорта и парсера

Внутри каждого сервиса можно выделить еще две сущности, которые могут варьироваться. Это способ кодирования (parser) и способ пересылки (transport) сообщений. (Действительно, мы должны иметь возможность сменить формат сообщений с JSON на XML или протокол пересылки с HTTP на TCP-сокеты без необходимости дублировать весь остальной код сервиса.) В самом простом виде их интерфейсы могут выглядеть так:

interface IParser
{
    // Methods
    public function serialize(commands:Dynamic):Dynamic;
    public function parse(plain:Dynamic):Array<Dynamic>;
}
interface ITransport
{
    // Settings
    public var url:String;
    // Signals
    public var receiveDataSignal(default, null):Signal<Dynamic>;
    public var errorSignal(default, null):Signal<Dynamic>;
    // Methods
    public function send(plainData:String, ?data:Dynamic):Void;
}

В случае использования сокетов потребуется расширить интерфейс добавлением в него методов и свойств для установления соединения (для HTTP-реализации ненужные методы и свойства можно оставить пустыми):

interface ITransport
{
    // Settings
    public var reconnectIntervalMs:Int;
    public var isOutputBinary:Bool;
    public var isInputBinary:Bool;
    // State
    public var host(default, null):String;
    public var port(default, null):Int;
    public var isConnecting(default, null):Bool;
    public var isConnected(get, null):Bool;
    // Signals
    public var connectingSignal(default, null):Signal<ITransport>;
    public var connectedSignal(default, null):Signal<ITransport>;
    public var disconnectedSignal(default, null):Signal<ITransport>;
    public var closedSignal(default, null):Signal<ITransport>;
    public var reconnectSignal(default, null):Signal<ITransport>;
    public var receiveDataSignal(default, null):SignalDyn;
    public var errorSignal(default, null):SignalDyn;

    // Methods
    public function connect(?host:String, ?port:Int):Void;
    public function close():Void;
    public function send(plainData:Dynamic):Void;
}

Но пока что сделаем пересылку JSON-объектов по HTTP-протоколу:

class JSONParser implements IParser
{
    // Methods
    public function serialize(commands:Dynamic):Dynamic
    {
      	return Json.stringify(commands);
    }
    public function parse(plain:Dynamic):Array<Dynamic>
    {
        if (plain == null)
        {
          	return null;
        }
        var data:Dynamic = Json.parse(plain);
        return Std.isOfType(data, Array) ? data : [data];
    }
}
class HTTPTransport implements ITransport
{
    // Settings
    public var url:String;
    // Signals
    public var receiveDataSignal(default, null) = new Signal<Dynamic>();
    public var errorSignal(default, null) = new Signal<Dynamic>();

    // Methods
    public function send(plainData:String, ?data:Dynamic):Void
    {
        var method = data != null ? data._method : null;
        var params = {data: plainData};
        new Request().send(method, url, params, function(data:Dynamic):Void {
            // Dispatch
            receiveDataSignal.dispatch(data);
        }, function(error:Dynamic):Void {
            // Dispatch
            errorSignal.dispatch(error);
        });
    }
}

Сам сервис превратится, во-первых, в класс, координирующий работу транспорта и парсера, а во-вторых, в своего рода абстрактный интерфейс к удаленной службе, который хранит информацию о именах команд, параметров и их типах:

class DresserService extends StorageService
{
    public function new()
    {
        super();
        url = "http://127.0.0.1:5000/storage/dresser";
    }
}
class StorageService implements IStorageService
{
    private var parser:IParser;
    private var transport:ITransport;
    //...
    public function new()
    {
        var ioc = IoC.getInstance();
        parser = ioc.create(IParser);
        transport = ioc.create(ITransport);
        transport.url = url;
        // Listeners
        transport.receiveDataSignal.add(transport_receiveDataSignalHandler);
    }
    //...
    public function setState(value:Dynamic):Void
    {
        var plainData = parser.serialize({state: value, _method: "POST"});
        transport.send(plainData);
    }
    public function changeItem(index:Dynamic, value:Dynamic):Void
    {
        var plainData = parser.serialize({index: index, value: value, _method: "PATCH"});
        transport.send(plainData);
    }
    //...
    private function processData(data:Dynamic):Void
    {
        switch data._method
        {
            case "GET" | null:
                // Dispatch
                loadSignal.dispatch(data.state);
            case "POST":
                // Dispatch
                stateChangeSignal.dispatch(data.state);
            case "PATCH":
                // Dispatch
                itemChangeSignal.dispatch(data.index, data.value);
        }
    }
    private function transport_receiveDataSignalHandler(plain:Dynamic):Void
    {
        var data:Dynamic = parser.parse(plain);
        if (data != null && data.success)
        {
            processData(data);
        }
    }
}
interface IStorageService
{
    // Signals
    public var loadSignal(default, null):Signal<Dynamic>;
    public var stateChangeSignal(default, null):Signal<Dynamic>;
    public var itemChangeSignal(default, null):Signal2<Dynamic, Dynamic>;
    // Methods
    public function load():Void;
    public function setState(value:Dynamic):Void;
    public function changeItem(index:Dynamic, value:Dynamic):Void;
}

Отделение протокола

В результате сервис становится тем местом, где концентрируется информация о внутреннем содержимом сообщений-команд, то есть о протоколе приложения. А так как все остальные функции вынесены в отдельные классы (parser и transport), то описание протокола становится теперь основной задачей сервиса. Поэтому его можно переименовать в Protocol:

class Protocol
{
    // Settings
    public var url = "";
    // State
    private var parser:IParser;
    private var transport:ITransport;

    public function new()
    {
        var ioc = IoC.getInstance();
        parser = ioc.create(IParser);
        transport = ioc.create(ITransport);
        transport.url = url;
        // Listeners
        transport.receiveDataSignal.add(transport_receiveDataSignalHandler);
    }
    public function send(data:Dynamic):Void
    {
        var plain:Dynamic = parser.serialize(data);
        Log.debug('<< Send: $data -> $plain');
        transport.send(plain, data);
    }
    // Override
    private function processData(data:Dynamic):Void
    {
    }
    private function transport_receiveDataSignalHandler(plain:Dynamic):Void
    {
        var data:Dynamic = parser.parse(plain);
        Log.debug(' >> Recieve: $plain -> $data');
        if (data != null && data.success)
        {
            processData(data);
        }
    }
}
class StorageProtocol extends Protocol implements IStorageService
{
    // Signals
    public var loadSignal(default, null) = new Signal<Dynamic>();
    public var stateChangeSignal(default, null) = new Signal<Dynamic>();
    public var itemChangeSignal(default, null) = new Signal2<Dynamic, Dynamic>();
    // Requests
    public function load():Void
    {
        send(null);
    }
    public function setState(value:Dynamic):Void
    {
        send({state: value, _method: Method.POST});
    }
    public function changeItem(index:Dynamic, value:Dynamic):Void
    {
        send({index: index, value: value, _method: Method.PATCH});
    }
    // Responses
    override private function processData(data:Dynamic):Void
    {
        super.processData(data);
        switch data._method
        {
            case "GET" | null:
                // Dispatch
                loadSignal.dispatch(data.state);
            case "POST":
                // Dispatch
                stateChangeSignal.dispatch(data.state);
            case "PATCH":
                // Dispatch
                itemChangeSignal.dispatch(data.index, data.value);
        }
    }
}

При переходе к сокетам вместо поля "_method" можно использовать имена или коды команд в явном виде (например, {"command": "add"}). Также можно пересылать вместо одной команды сразу целый массив. Во многих случаях так будет удобнее.

Идею абстрактного интерфейса к некоей службе можно развить. На самом деле тут реализовано два интерфейса. Один — это для моделей, а другой — API самого сервера. К интерфейсу клиента относятся сигнатуры методов и возвращаемые сигналами команды. Серверный интерфейс состоит из формата отсылаемых и получаемых сервисом (класс Protocol) команд. Если они по какой-то причине не совпадают (например, если сервер разрабатывается независимо от клиента), то преобразования интерфейсов можно производить в самом сервисе (Protocol) или в парсере (создать специальный подкласс). Данный класс, таким образом, станет адаптером, согласующим разные интерфейсы.

Отделение контроллера

Получившийся класс для протокола в тонком клиенте может использоваться компонентами отображения напрямую, без задействования моделей. Модели нужны для хранения и обработки данных. А зачем нам данные, если нет никакой логики, которая бы их использовала. В тонком клиенте все данные лишь отображаются, а для этого их хранить необязательно. Данные при каждом изменении напрямую передаются в отображение и, если это нужно, то хранятся там, в его внутренних переменных.

В таком случае получается, что отображение вызывает методы протокола, а потом обновляет графику по получении от него сигнала. Другими словами, класс протокола становится неотличим от контроллера. А в случае standalone-игры он и есть контроллер, так как вместо обращений к серверу будет содержать саму логику. Тогда почему бы Protocol и не называть контроллером? Когда мы в классе реализуем протокол в общем виде, это еще Protocol, а когда мы наследуемся от него, чтобы настроить и использовать в деле, то тут уже будем называть его Controller:

class DresserController extends StorageProtocol
{
    public function new()
    {
        super();
        transport.url = "http://127.0.0.1:5000/storage/dresser";
    }
}

MVC

Так — для тонкого клиента. Но в более общей схеме, где может добавляться кое-какая логика на стороне клиента ("толстый" клиент, standalone-приложение), для этой логики нужно хранить данные. То есть нужны модели. Компоненты по-прежнему на каждое действие пользователя вызывают методы контроллера, тот делает запрос на сервер, а полученный ответ сохраняет в модели. Компонент слушает сигналы модели и вносит соответствующие изменения в графику. (Некоторые сигналы могут находиться сразу в контроллерах.)

Общая MVC-схема всех наших приложений
Общая MVC-схема всех наших приложений

Вот так мы пришли к классической схеме MVC-паттерна. Эта схема работает и для тонкого клиента, и для толстого, и для локальной (standalone) версии — годится на все случаи жизни. В последнем случае контроллер вместо отсылки запроса на сервер сам реализует правила игры и манипулирует моделями.

Определенная группа тесно связанных между собой компонентов, контроллеров и моделей может образовывать небольшие модули, которые могут повторно использоваться в разных приложениях как единое целое. Так, можно отдельным модулем реализовать систему достижений (achievements), платежку (с магазинами и промо-акциями), рейтинги, бонусы и любой вообще другой мета-геймплей. В виде отдельного MVC-модуля реализуются и сами игры (геймплей). По сути Base Game Frameworks и Game Frameworks только из таких модулей и состоят, тогда как одиночные классы все помещаются в Core Framework.

Команды

Отдельно скажем пару слов о командах, которые хоть и вплелись в наш рассказ как бы сами собой, но при этом сами являются вещью не совсем тривиальной и очевидной. Командами мы называем сообщения, которыми клиент обменивается с сервером. В терминах ООП уже сам вызов метода является сообщением, которое один объект передает другому. Мы бы так и пользовались бы простыми вызовами методов, если бы нам не нужно было передавать сообщения на другие машины, если бы не нужно было реализовывать сетевой режим. Мы не можем вызвать метод напрямую на удаленной машине, а потому мы вынуждены использовать вместо них объекты с полем command вместо имени метода и другими полями, заменяющими аргументы. (Эти объекты для пересылки по сети обычно кодируются в JSON-формат, хотя можно использовать и любой другой, в том числе и свой собственный.)

Изначально команды создавались в классе протокола при вызове одного из его методов. То есть сообщение в виде вызова метода конвертировалось в сообщение в виде объекта. В обратном направлении объект конвертировался в тот или иной сигнал контроллера или модели. Но со временем быстро стало понятно, что нет смысла создавать сигнал для каждой команды. Проще в компонентах обрабатывать сами команды, какие они есть. Команды переходят от парсера в отображение в неизменном виде, где и обрабатываются. Этот же механизм можно использовать и в обычных локальных играх, без сетевого режима.

Выясняется, что гораздо удобнее создавать отображение сразу под команды. Этим мы не только делаем его автоматически пригодным для сетевой игры, не только не привязываемся к определенному типу контроллеров, но и делаем возможным записывать и проигрывать повторы игры. Повторы можно сохранять на диск, а также отсылать вместе с отчетами об ошибке разработчикам для отладки. И если игрокам повторы, может быть и не очень нужны, хотя это и прикольно, но в качестве инструмента отладки они могут оказаться порой незаменимыми. В реальной разработке полные повторы игры редко можно встретить, но это не потому, что они не нужны, а потому, что их очень накладно реализовывать, если они не заложены в проект изначально. А тут они достаются нам даром, в нагрузку к сетевому режиму.

В общем, команды — это круто. И дальше мы переходим к тому, как реализуется их обработке на стороне сервера.

Содержание

Постановка проблемы

Клиент

  1. Логика отображения

  2. Менеджеры и другие классы

  3. Слои логики

Сервер

  1. Слои инфраструктуры

  2. Слои логики

Подведение итогов

< Назад | Начало | Вперед >

Исходники

Полная версия руководства (версия для начинающих)

Теги:
Хабы:
+3
Комментарии0

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн