The evolution of the game framework. Client 3. Layers of logic

Before we looked at the separation of display logic from graphics, as well as various helper classes and managers. All together they form the framework of our applications and were moved to a separate library – Core Framework. It remains to develop a methodology for writing the rest of the logic. It includes business logic and game rules, data and its processing, as well as interaction with the server.

All logic will be divided into layers. The main meaning of the layers is that the classes of one layer are as independent as possible from the classes from neighboring layers and are absolutely independent from the rest. All this no longer applies to the main framework (Core Framework), but to frameworks for different groups of genres (Base Game Frameworks) and for each individual genre (Game Frameworks).

This results in the following hierarchy of libraries:

Business Logic Department

So far, we have had all the logic in the components (Component), which means that we have not yet made a separation between the display logic (view logic) and the logic of the subject area (domain logic), or business logic (business logic). That is, both the display logic and the business logic are implemented in the same class. This is nothing if one person is involved in the project, but if there are several of them, then it becomes difficult to share powers in the team. Yes, and the class itself can become large and confusing.

However, we do not separate different types of logic out of simple love for order. It is very difficult to use different display logics with the same business logic (and vice versa) if they are merged into one class. Also, you cannot develop them separately from each other: if we inherit from a class to complicate the display logic, we automatically drag the display logic with us, because they are all implemented in one class. Also, they cannot be used separately. For example, if the business logic is implemented on the server, and we only need display logic in the client.

To better understand how one logic differs from another, consider the following example. Let’s say we have components for displaying different pieces on a chessboard. On this board you can play chess, checkers, giveaway, corners and many other games. The visualization of the state of the game and the movement of elements on the board is the display logic, and the rules of the game, according to which the necessary commands are sent at the right time, are the business logic.

Here, for all the listed games, only one module of one or more components will be enough to display figures or checkers on the board. And for each new game, one class with rules (controller) will then be created. And if we are making a thin client that uses a generalized, universal protocol, then there is no need to do this either – the creation of business logic is shifted to the server team.

Model

The program is a reflection of reality. Unfortunately, we are not yet able to repeat reality in its entirety. Yes, this is not necessary. To solve specific problems, it is enough for us to reflect only its main, essential aspects and relations. That is to build a model of reality. In our case, the model is represented by two aspects: state and actions. State is data stored in variables, that is, in memory, and actions are instructions that are executed by the processor. Processor instructions are too primitive, so we will take functions as the minimum units of action.

Those classes that contain data and functions for their direct processing and nothing else, we will call models. In our game, the model class consists of a configuration of the selected clothing numbers of each type (an array of integers state) and functions that change individual clothes (changeItem()) or all of them together (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);
        }
    }
}

The main (and only) game component Dresser with the model will look a little more massive than before:

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);
        }
    }
}

Service department

So far, it is not clear why it is so complicated. But it is worth adding the need to support the server to the list of requirements, and the justification for this decision becomes obvious. We can implement the second model with support for network mode, and at the same time we will also have its first version – for standalone mode. We can substitute any component as we wish, since they have one interface, and for Dresser they are indistinguishable.

But the servers may be different. It may be an HTTP server, it may be on sockets. In addition, all of them may have different versions with a different protocol. In order not to create a separate model for each, we will take out the code responsible for connecting and sending data to a special class – a service, or a service. It is called a service because, from the point of view of the application, it performs a certain service function. In this case, this is a centralized storage of data on a remote machine.

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;
    }
}

Together, all services form a layer of services, and models, surprisingly, a layer of models. Just as there can be different implementations of models for the same view, so there can be multiple versions of services that fit the same model. Each layer has its own space for development, limited by a set of public properties and methods. And as long as the changes do not go beyond the class interface, they do not entail changes in the classes of other layers, they are a purely internal matter.

Transport and parser department

Within each service, two more entities can be distinguished, which may vary. This is a way to encode (parser) and a way to send (transport) messages. (Indeed, we should be able to change the message format from JSON to XML, or the forwarding protocol from HTTP to TCP sockets, without having to duplicate all the rest of the service code.) In their simplest form, their interfaces might look like this:

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;
}

In the case of using sockets, you will need to extend the interface by adding methods and properties to establish a connection to it (for an HTTP implementation, unnecessary methods and properties can be left empty):

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;
}

But for now let’s do sending JSON objects over HTTP protocol:

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);
        });
    }
}

The service itself will turn, firstly, into a class that coordinates the work of the transport and the parser, and secondly, into a kind of abstract interface to the remote service that stores information about command names, parameters and their types:

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;
}

Department of Protocol

As a result, the service becomes the place where information about the internal content of command messages, that is, about the application protocol, is concentrated. And since all other functions are placed in separate classes (parser and transport), the description of the protocol now becomes the main task of the service. Therefore, it can be renamed to 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);
        }
    }
}

When going to sockets instead of field "_method" you can use explicit command names or codes (for example, {"command": "add"}). You can also send a whole array instead of one command at once. In many cases, this will be more convenient.

The idea of ​​an abstract interface to a service can be developed. In fact, there are two interfaces implemented here. One is for the models, and the other is the API of the server itself. The client interface includes method signatures and commands returned by signals. The server interface consists of the format sent and received by the service (class Protocol) commands. If for some reason they do not match (for example, if the server is developed independently of the client), then interface transformations can be performed in the service itself (Protocol) or in the parser (create a custom subclass). This class, therefore, will become an adapter, matching different interfaces.

Controller branch

The resulting class for the protocol in the thin client can be used directly by display components without involving models. Models are needed to store and process data. And why do we need data if there is no logic that would use them. In the thin client, all data is only displayed, and for this it is not necessary to store them. The data is transferred directly to the display with each change and, if necessary, stored there, in its internal variables.

In this case, it turns out that the display calls the methods of the protocol, and then updates the graphics when it receives a signal from it. In other words, the protocol class becomes indistinguishable from the controller. And in the case of a standalone game, it is the controller, since instead of accessing the server it will contain the logic itself. Then why not call Protocol a controller? When we implement the protocol in a general form in a class, it is still Protocol, and when we inherit from it in order to configure and use it in practice, then we will already call it Controller:

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

MVC

So – for a thin client. But in a more general scheme, where some logic can be added on the client side (“thick” client, standalone application), data must be stored for this logic. That is, we need models. The components still call the controller methods for each user action, the controller makes a request to the server, and saves the received response in the model. The component listens to model signals and makes appropriate changes to the graphics. (Some signals may be directly in the controllers.)

General MVC scheme for all our applications
General MVC scheme for all our applications

This is how we arrived at the classic MVC pattern. This scheme works for a thin client, and for a thick one, and for a local (standalone) version – it is suitable for all occasions. In the latter case, instead of sending a request to the server, the controller itself implements the rules of the game and manipulates the models.

A certain group of closely related components, controllers, and models can form small modules that can be reused in different applications as a whole. So, it is possible to implement a system of achievements (achievements), a payment system (with shops and promotions), ratings, bonuses, and any other meta-gameplay in general as a separate module. The games themselves (gameplay) are also implemented as a separate MVC module. In fact, Base Game Frameworks and Game Frameworks only consist of such modules, while single classes are all placed in the Core Framework.

Teams

Separately, let’s say a few words about the commands, which, although they were woven into our story as if by themselves, but at the same time they themselves are not a completely trivial and obvious thing. Commands are the messages that the client exchanges with the server. In OOP terms, the method call itself is a message that one object passes to another. We would still use simple method calls if we did not need to send messages to other machines, if we did not need to implement network mode. We cannot call a method directly on a remote machine, so we are forced to use objects instead with a command field instead of the method name and other fields that replace arguments. (These objects are usually JSON-encoded for transfer over the network, although you can use any other format, including your own.)

Initially, commands were created in the protocol class when one of its methods was called. That is, a message in the form of a method call was converted into a message in the form of an object. In the opposite direction, the object was converted into one or another signal of the controller or model. But over time, it quickly became clear that it makes no sense to create a signal for each team. It’s easier to process the commands themselves in the components as they are. The commands pass from the parser to the display unchanged, where they are processed. The same mechanism can be used in normal local games, without network mode.

It turns out that it is much more convenient to create a display immediately under the commands. By doing this, we not only make it automatically suitable for online play, not only are we not tied to a specific type of controller, but we also make it possible to record and play game replays. Replays can be saved to disk and sent along with bug reports to developers for debugging. And if players don’t really need repetitions, although it’s cool, but as a debugging tool, they can sometimes be indispensable. In real development, full replays of the game are rare, but this is not because they are not needed, but because they are very expensive to implement if they are not included in the project initially. And here they get to us for free, in the load to the network mode.

In general, teams are cool. And then we move on to how their processing is implemented on the server side.

< Back | Home | Next >

Sources

Full version of the guide (for beginners)

Similar Posts

Leave a Reply