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

Все вместе уже можно будет считать вполне оформившимся игровым фреймворком.

Скрины

При старте игрового приложения обычно первым показывается экран меню. После клика на кнопку "Начать игру" экран меню уничтожается и показывается экран игры. Каждый экран делается в виде компонента, соответственно, и логика смены экранов тоже нужно реализовать в компоненте. Назовем его Screens. Это будет самый корневой компонент, а потому в качестве скина он будет содержать ссылку на Main:

class Main extends Sprite
{
    public function new()
    {
        super();
        new Screens(this);
    }
}
class Screens extends Component
{
		// State
    private static var instance:Screens;
    public static function getInstance():Screens
    {
        if (instance == null)
        {
            instance = new Screens();
        }
        return instance;
    }
    public var currentScreen(default, null):Screen;
    private var currentScreenClass:Class<Screen>;

    // Init
    public function new()
    {
        super();
        if (instance != null)
        {
            throw new Exception("Singleton class can have only one instance!");
        }
        // Should be set only once
        instance = this;
    }
    override public function dispose():Void
    {
        if (currentScreen != null)
        {
            currentScreen.dispose();
            currentScreen = null;
        }
        super.dispose();
    }
    // Methods
    public function open(type:Class<Screen>):Screen
    {
        if (currentScreenClass == type)
        {
            // Skip same
            return currentScreen;
        }
        close(currentScreen);
        // Open new
        currentScreenClass = type;
        currentScreen = Std.downcast(_open(cast type), Screen);
        // Move new screen to the bottom, under all global dialogs (and other elements if any)
        moveToBottom(currentScreen);
        return currentScreen;
    }

    private function _open(type:Class<Component>):Component
    {
        if (type == null)
        {
          	return null;
        }
        // Get or create
        var instance:Component = createComponent(type);
        // Add
        addChild(instance);
        return instance;
    }
    // For both screens and dialogs
    public function close(instance:Component):Void
    {
        if (instance == null)
        {
          	return;
        }
        // Remove
        if (instance == currentScreen)
        {
            currentScreen = null;
            currentScreenClass = null;
            // Dispose
            instance.dispose();
        }
    }
    private function moveToBottom(component:Component):Void
    {
        if (component != null && component.skin != null && component.skin.parent != null)
        {
          	component.skin.parent.addChildAt(component.skin, 0);
        }
    }
}

Сменить скрин можно из любого места в программе: Screens.getInstance().open(MenuScreen);.

Диалоги

Аналогично скринам можно реализовать и диалоги. Диалоги отличаются от скринов только тем, что одновременно можно показывать только один скрин, а диалогов — сколько угодно. Поэтому для них нужно создать отдельный метод — openDialog().

Диалоги бывают двух типов: глобальные и локальные. Первые добавляются в компонент Screens, вторые — в текущий скрин. Локальные уничтожатся вместе со скрином, а глобальные так и будут висеть до тех пор, пока их не закроешь вручную. Еще, чтобы новый скрин не перекрывал глобальные диалоги, сразу после создания его скин перемещается в самый низ списка отображения (display list). Вот, собственно, и вся логика диалогов:

class Screens extends Component
{
    // State
    // ...
    private var dialogStack:Array<Component> = []; // To disable all under modal
    private var localDialogs:Array<Component> = []; // To close dialogs related to previous screen

    // Init
    override public function dispose():Void
    {
        for (dialog in dialogStack)
        {
          	dialog.dispose();
        }
        dialogStack.resize(0);
        localDialogs.resize(0);
        // ...
    }
    // Methods
    public function open(type:Class<Screen>):Screen
    {
        if (currentScreenClass == type)
        {
            // Skip same
            return currentScreen;
        }
        // Close previous
        for (dialog in localDialogs)
        {
            close(dialog);
        }
        // ...
    }
    public function openDialog(type:Class<Component>, ?global:Bool):Component
    {
        var dialog = _open(type);
        if (dialog == null)
        {
          	return null;
        }
        dialogStack.push(dialog);
        if (!global)
        {
          	localDialogs.push(dialog);
        }
        return dialog;
    }

    // For both screens and dialogs
    public function close(instance:Component):Void
    {
        if (instance == null)
        {
            return;
        }
        // Remove
        if (instance == currentScreen)
        {
            currentScreen = null;
            currentScreenClass = null;
            // Dispose
            instance.dispose();
        }
        if (dialogStack.remove(instance))
        {
            // Dispose
            instance.dispose();
        }
        localDialogs.remove(instance);
    }
}

В качестве скрина и диалога может выступать любой компонент с заданным assetName. Но можно создать отдельно Screen и Dialog, в которых будут реализованы такие стандартные для них функции, как кнопка закрытия (closeButton), включение музыки, модальность, масштабируемость и т.д.

Скрины и диалоги в нашем приложении будут основными источниками графики и практически единственными классами с заданными assetName. Они в свою очередь обычно состоят из компонентов-панелей, координирующих работу элементарных UI-компонентов. В конце цепочки стоят сами UI-компоненты, которые не знают о других компонентах, а занимаются только обработкой собственного скина. И панели и UI-элементы получают свой скин по skinPath.

Инверсия управления (IoC)

Итак, корневой компонент у нас Screens. Он создает компоненты для скринов и диалогов, которые содержат собственную иерархию компонентов вплоть до простых UI-компонентов. Для повторного использования эти скрины и диалоги вместе с зависимыми классами можно выносить в библиотеки. При этом, если где-то в середине иерархии для очередного клона игры нужно изменить одно из свойств или кое-что в логике, то придется переопределять всю цепочку вверх по иерархии вплоть до самого скрина или диалога. В результате создается несколько подклассов только для подмены одного класса на другой. Например:

class MenuScreen2 extends MenuScreen
{
    override private function init():Void
    {
    		settingsPanelClass = SettingsPanel2
        super.init();
    }
}
class SettingsPanel2 extends SettingsPanel
{
    override private function init():Void
		{
        super.init();
        langPanel.skinPath = "";
    }
}

Тут еще придется подменить все использования MenuScreen на MenuScreen2, что повлечет за собой по цепной реакции еще целый шквал новых подклассов.

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

class SettingsPanel2 extends SettingsPanel
{
    override private function init():Void
		{
        super.init();
        langPanel.skinPath = "";
    }
}
class Main extends Sprite
{
    public function new()
    {
        registerSubstitution(SettingsPanel, SettingsPanel2);
        // ...
    }
		// ...
}

Но не слишком ли это оптимистично с нашей стороны? Можно ли это реализовать? Можно и при том в этом нет никакой проблемы. Так как все компоненты у нас создаются с помощью метода createComponent(), то нужно просто в нем осуществлять подмену одного класса на другой.

class MenuScreen extends Component
{
    //...
    private function init():Void
    {
        //...
        settingsPanel = createComponent(SettingsPanel);
        //...
    }
    //...
}

Для этого в каждый компонент нужно передавать словарь подмены типов, или ссылку на объект, который его содержит. В данном случае это Main. Но так как Main может содержать еще кучу всего, что компонентам знать не нужно, выделим эту его задачу в отдельный класс — IoC-контейнер, или просто IoC (Inversion of Control, инверсия управления):

class IoC
{
    // Static
    private static var instance:IoC;
    public static function getInstance():IoC
    {
        if (instance == null)
        {
            instance = new IoC();
        }
        return instance;
    }
    // Settings
    private var realTypeByTypeName:Map<String, Class<Dynamic>> = new Map();

    public function new()
    {
        if (instance != null)
        {
            throw new Exception("Singleton class can have only one instance!");
        }
        instance = this;
    }
    public function register<T>(type:Class<T>, realType:Class<T>):Void
    {
        var typeName = Type.getClassName(type);
        realTypeByTypeName[typeName] = realType;
    }
    public function create<T>(type:Class<T>, ?args:Array<Dynamic>):T
    {
        if (type == null)
        {
            return null;
        }
        var typeName = Type.getClassName(type);
        var realType = realTypeByTypeName[typeName];
        if (realType == null)
        {
            realType = type;
        }
        return Type.createInstance(realType, args);
    }
}

Так как он регистрирует и хранит все подмены типов, то и создание всех экземпляров в приложении также должно проходить через него (create()). Чтобы быть доступным отовсюду, IoC сделан синглтоном. Все экземпляры классов теперь создаются не через Type.createInstance(), а через IoC.getInstance().create(). Именно в create() и осуществляется подстановка подмененного типа. Если подмены нет, то используется тип, переданный в аргументе:

class Component
{
    private var ioc = IoC.getInstance();
    //...
    private function createComponent<T>(type:Class<T>):T
    {
        return ioc.create(type);
    }
}
class Main extends Sprite
{
    public function new()
    {
	    	var ioc = IoC.getInstance();
        ioc.register(SettingsPanel, SettingsPanel2);
        // ...
    }
		// ...
}

Что означает инверсия управления (Inversion of Control)? В обычном случае каждый класс сам создает объекты, которые ему нужны для работы, и, соответственно, сам определяет их типы. Но если он перепоручит создание зависимостей другому классу — IoC-контейнеру — а сам будет только вызывать его метод create(), то управление переместится изнутри класса наружу. Уже не сам класс будет определять свое наполнение, а IoC-контейнер. Таким образом, произойдет инверсия управления. Настраивать IoC-контейнер, а, следовательно, и все приложение можно теперь централизованно, из одного места: из Main или вовсе из внешнего конфиг-файла (xml, yml).

В классической реализации все свойства объекта задаются автоматически тем же IoC-контейнером сразу же после создания объекта — исходя из типов и метаданных свойств. Это называется внедрением зависимостей (Dependency Injection, DI). Тогда для непосвященного все и вовсе начинает выглядеть как полнейшая магия: свойства класса нигде не присваиваются в коде, но при всем этом все же каким-то неведомым образом обладают значениями. Как? Почему? В больших приложениях без специального объяснения самому в этом практически не разобраться.

Чтобы не морочить другим голову, и чтобы не закапываться с рефлексией и метаданными, мы создаем все экземпляры в простом и явном виде — в методе create(). То есть мы отказываемся от автоматической инъекции зависимостей ради большей простоты и очевидности кода.

Через IoC-контейнер также должны проходить и все синглтоны, чтобы и им можно было подменять реализации:

class Screens
{
    // Static
    private static var instance:Screens;
    public static function getInstance():Screens
    {
        if (instance == null)
        {
            instance = IoC.getInstance().create(Screens);
        }
        return instance;
    }
    //...
}

А раз так, то сам механизм синглтонов можно полностью перенести из статичных методов в IoC:

class IoC
{
    //...
    public function getSingleton<T>(type:Class<T>):T
    {
        if (type == null)
        {
          	return null;
        }
        var typeName = Type.getClassName(type);
        // Get
        var instance = singletonByTypeName[typeName];
        if (instance == null)
        {
            // Create
            instance = create(type);
            // Save by base type
            singletonByTypeName[typeName] = instance;
            // Save also by current type
            var currentType = Type.getClass(instance);
            var currentTypeName = Type.getClassName(currentType);
            singletonByTypeName[currentTypeName] = instance;
        }
        return cast instance;
    }
}
class Main extends Sprite
{
    public function new()
    {
        super();
        var ioc = IoC.getInstance();
        // Config
        ioc.register(SettingsPanel, SettingsPanel2);
        // Start
        var screens:Screens = ioc.getSingleton(Screens)
        screens.skin = this;
    }
}

Конфиг

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

LangPanel:
    skinPath: ""
class IoC
{
    //...
    public function load(assetName:String=null):Void
    {
    		// ...
    }
    public function create<T>(type:Class<T>):T
    {
        if (type == null)
        {
            return null;
        }
        var typeName = Type.getClassName(type);
        var realType = realTypeByTypeName[typeName];
        var result = Type.createInstance(realType != null ? realType : type, []);
        applyConfig(result, typeName, realType);
        return result;
    }
    private function applyConfig<T>(result:T, typeName:String, ?realType:Class<Dynamic>):Void
    {
      	// ...
    }
}

Тут при создании экземпляра класса с именем LangPanel его свойству skinPath сразу присваивается значение "". Свойства можно определять цепочкой имен, разделенных точками, поэтому то же самое можно реализовать еще двумя способами:

SettingsPanel:
    langPanel.skinPath: ""
Menu:
    settingsPanel.langPanel.skinPath: ""

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

MenuBase:
    settingsPanel.langPanel.skinPath: ""
Menu:
    super: MenuBase

Вложенность может иметь неограниченную глубину — лишь бы она незакольцовывалась.

Сеанс одновременной игры (MultiApplication)

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

Но если мы захотим в одном приложении запустить одновременно две игры (что актуально, например, для шахмат или покера), то мы столкнемся с проблемами. Игры будут использовать одни и те же синглтоны для скринов, контроллеров, соединений с сокет-сервером и так далее, а значит, никакой одновременной игры не получится. Для этого нужно разделить контексты приложений, или, другими словами, использовать разные экземпляры IoC-контейнера.

Мы больше не можем использовать метод IoC.getSingleton() — ссылка на IoC теперь должна передаваться объекту явным образом при его создании. И лучшего места, чем все тот же метод create() не найти. IoC первым делом будет передавать ссылку на самого себя всем объектам, имеющим свойство ioc:

class IoC
{
    //...
    public function create<T>(type:Class<T>):T
    {
        if (type == null)
        {
            return null;
        }
        var typeName = Type.getClassName(type);
        var realType = realTypeByTypeName[typeName];
        var result = Type.createInstance(realType != null ? realType : type, []);
        if (Reflect.hasField(result, "ioc"))
        {
            Reflect.setProperty(result, "ioc", this);
        }
        applyConfig(result, typeName, realType);
        return result;
    }
}

В результате мы не только избавили нас от необходимости каждый раз вызывать IoC.getSingleton() в классах, но и существенным образом реорганизовали приложение в "мультиприложение", при том всего в две строчки.

Так как класс может действовать и создавать другие объекты внутри себя, только имея ссылку на ioc, и тут же теряет эту способность при ее удалении, то присваивание и удаление ioc становится важной частью жизненного цикла класса. По аналогии со скином компонента сделаем ioc в виде сеттера, который будет вызывать init() при установлении нового значения и dispose() — при удалении. Поскольку это будет нужно не только для компонентов, то чтобы не дублировать данный код в разных классах, вынесем его в отдельный базовый класс Base:

class Base
{
    // State
    private var ioc(default, set):IoC;
    private function set_ioc(value:IoC):IoC
    {
        if (ioc != value)
        {
            if (ioc != null)
            {
                // (To prevent calling set_ioc() again on ioc=null in dispose())
                ioc = null;
                // Dispose previous state
                dispose();
            }
            // Set
            ioc = value;
            if (ioc != null)
            {
                // Initialize
                init();
            }
        }
        return value;
    }
    // Override to change defaults and other settings which should be set only once
    public function new()
    {
    }
    // Override for initialization (create/get instances using IoC)
    private function init():Void
    {
    }
    // Override to revert changes of init and clear up the memory
    public function dispose():Void
    {
        ioc = null;
    }
}
class Component extends Base
{
    // ...
}
class IoC extends Base
{
    //...
    public function new()
    {
        super();
        ioc = this;
    }
    override public function dispose():Void
    {
        for (instance in singletonByTypeName)
        {
            var base = Std.downcast(instance, Base);
            if (base != null && base != this)
            {
                base.dispose();
            }
        }
        super.dispose();
    }
}

Метод init() выполняется, когда ioc уже задан (не равен null). Поэтому в нем может происходить основная инициализация объекта. Метод dispose() противоположен init() и предназначен для очистки ресурсов, выделенных в init().

Как видим, даже сам IoC можно наследовать от Base. Наличие метода dispose() позволяет нам уничтожать все хранящиеся внутри синглтоны при уничтожении IoC-контейнера. Этим подтверждается правило: кто создал, тот и несет ответственность за уничтожение. IoC-контейнер будет уничтожаться вместе со своим приложением, когда уменьшится количество загруженных игр.

Сопутствующие классы

Сигналы

Чтобы избавиться от неуклюжей системы событий OpenFL (Flash), мы в компонентах и прочих классах будем пользоваться собственного изготовления сигналами. Сигналы — это тот же вызов колбека, только с возможностью вызывать сразу несколько колбеков вместо одного. Поэтому, чтобы хранить список функций создается отдельный объект Signal. Для удобства в него добавляются также методы: dispatch(), add(), remove():

class Signal<T>
{
    private var listeners = new Array<(T)->Void>();
    public function add(listener:(T)->Void):Void
    {
        if (listener != null && !listeners.contains(listener))
        {
            listeners.push(listener);
        }
    }
    public function remove(listener:(T)->Void):Void
    {
        if (listener != null)
        {
            listeners.remove(listener);
        }
    }
    public function dispose():Void
    {
        listeners.resize(0);
    }
    public function dispatch(target:T):Void
    {
        // Traverse listeners' copy, because some listeners could be
        // added or removed during dispatching and the list would change
        for (listener in listeners.copy())
        {
            listener(target);
        }
    }
}

Использование сигналов дает нам следующие преимущества:

  1. Мы теперь не обязаны наследоваться от EventDispatcher.

  2. Нам не нужно создавать объект Event для всякого события, даже если его никто не слушает.

  3. Для события с новой комбинацией параметров потребовалось бы создавать новый класс. В сигналах типы параметров указываются при создании экземпляра сигнала, а имена — в самих слушателях (и комментариях).

class Button extends Component
{
    public var clickSignal(default, null) = new Signal<Button>; // handler(target:Button):Void
    //...
    private function interactiveObject_clickHandler(event:MouseEvent):Void
    {
        clickSignal.dispatch(this);
    }
}
class Dialog extends Component
{
    //...
    private var closeButton:Button;
    public function new()
    {
        super();
        closeButton = new Button();
        closeButton.skinPath = "closeButton";
        closeButton.clickSignal.add(closeButton_clickSignalHandler);
        addChild(closeButton);
    }
    //...
    private function closeButton_clickSignalHandler(target:Button):Void
    {
    }
}

Если нужно передавать в слушатели 2, 3, 4 аргумента, то нам ничего не остается как создать копию класса Signal с большим числом параметров:

class Signal2<T1, T2>
{
    private var listeners = new Array<(T1, T2)->Void>();
    //...
}
class Signal3<T1, T2, T3>
{
    private var listeners = new Array<(T1, T2, T3)->Void>();
    //...
}

Помимо экономичности сигналы делают наш код более лаконичным и аккуратным, так как используют максимально короткие имена методов: add() вместо addEventListener(), dispatch() вместо dispatchEvent() и т.д.

Логи

Чтобы разделять все логи по важности с возможностью отключать некоторые из них, будем вместо стандартного trace() использовать статический класс-обертку Log:

class Log
{
    // Settings
    public static var isDebug = true;
    public static var isInfo = true;
    // Methods
    public static function debug(message:Dynamic, ?posInfo:PosInfos):Void
    {
        if (isDebug)
        {
            Log.trace("debug: " + message, posInfo);
        }
    }
    public static function info(message:Dynamic, ?posInfo:PosInfos):Void
    {
        if (isInfo)
        {
            Log.trace(message, posInfo);
        }
    }
    public static function warn(message:Dynamic, ?posInfo:PosInfos):Void
    {
        Log.trace("WARNING! " + message, posInfo);
    }
    public static function error(message:Dynamic, ?posInfo:PosInfos):Void
    {
        Log.trace("ERROR!! " + message, posInfo);
    }
    public static function fatal(message:Dynamic, ?posInfo:PosInfos):Void
    {
        Log.trace("FATAL!!! " + message, posInfo);
    }
    private static dynamic function trace(v:Dynamic, infos:PosInfos):Void {
        #if (fdb || native_trace)
        var str = haxe.Log.formatOutput(v, infos);
        untyped __global__["trace"](str);
        #else
        flash.Boot.__trace(v, infos);
        #end
    }
}

Также это позволяет форматировать все логи изменением всего одного класса, а не внося изменения по всему приложению. Например, можно всего одной строкой добавить время к каждой строке, а также выделить некоторые типы сообщений особым префиксом ("WARNING!", "ERROR!! ", "FATAL!!!"). Пока везде в коде используется класс Log, содержимое логов находится под полным нашим контролем.

В будущем, чтобы добавить возможность отдельно настраивать разные каналы логов, класс Log можно переделать из статического в обычный. В этом случае он будет использоваться так:

Log.set("default", Log.INFO)
Log.set("managers", Log.DEBUG)
// ...
var log = Log.get("managers");
log.debug("Play sound");

Из статичных методов в таком случае будет использоваться экземпляр лока по умолчанию: Log.get("default").

Менеджеры

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

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

Реализуем в ResourceManager единообразное API (createMovieClip()) на все случаи: и когда ресурсы загружены, и когда их только предстоит загрузить:

class ResourceManager extends Base
{
		// ...
    public function createMovieClip(assetName:String, callback:(MovieClip) -> Void):Void
    {
        if (assetName == null || assetName == "")
        {
            callback(null);
            return;
        }
        if (assetName == "MovieClip" || assetName == "openfl.display.MovieClip")
        {
            callback(new MovieClip());
            return;
        }
        // Get
        var movieClip = getMovieClip(assetName);
        if (movieClip != null)
        {
            callback(movieClip);
            return;
        }
        // Load
        var libraryName = getLibraryName(assetName);
        loadLibrary(libraryName, function(libraryName:String):Void
        {
            var movieClip = Assets.getMovieClip(assetName);
            callback(movieClip);
        });
  	}
    public function getMovieClip(assetName:String):MovieClip
    {
        if (assetName == null || assetName == "")
        {
          	return null;
        }
        var libraryName = getLibraryName(assetName);
        if (!loadedLibraries.contains(libraryName))
        {
            // Not loaded yet
            return null;
        }
        return Assets.getMovieClip(assetName);
    }
    private function getLibraryName(assetName:String):String
    {
        return assetName.indexOf(":") != -1 ? assetName.split(":")[0] : "default";
    }
    public function getSound(assetName:String):Sound
    {
        // As sounds and music are not necessary part of game, ignore if they absent
        try
        {
            return Assets.getSound(assetName);
        }
        catch (e:Exception)
        {
            return null;
        }
    }
    public function getData(assetName:String):Dynamic
    {
        var texttext = Assets.getText(assetName);
        var nameParts = assetName.split(".");
        var ext = nameParts.length > 1 ? nameParts[nameParts.length - 1] : "";
        switch (ext)
        {
            case "json":
              	return Json.parse(text);
            // Add "<haxelib name="yaml"/>" to project.xml
            case "yml" | "yaml":
              	return Yaml.parse(text, new ParserOptions().useObjects());
        }
        return text;
    }
}

Для звуков мы сделали необязательным их наличие, а для текстовых данных — автоматический парсинг в зависимости от расширения файла. При использовании только Assets все это пришлось бы делать прямо в играх.

Для управления звуками можно создать AudioManager со свойствами isSoundOn, isMusicOn, isAudioOn (вкл/выкл), soundVolume, musicVolume, audioVolume (громкость) и методами playSound(), playMusic(), stopAllSounds(), stopMusic(). Состояние всех свойств можно сохранять в SharedObject, а потом при старте приложения загружать значения оттуда. Так, все изменения пользователя будут запоминаться, и ему не потребуется настраивать игру при каждом запуске.

Звуки (sound) отличаются от музыки (music) тем, что они могут проигрываться одновременно, тогда как музыкальные треки могут только сменять друг друга. Также методом playMusic() можно запускать плейлист из треков (например, если передать массив вместо строки). Конфигурацию каждого звукового файла (громкость, баланс, имя ресурса и т.д.) можно задавать во внешнем yml-файле такого формата:

isSoundOn: false
#isMusicOn: true
soundVolume: 0.7
musicVolume: .3

sound:
  click_button:  # Default soundName in Button
  	name: click_sound  # Real audio file name in assets
    pan: -1
music:
  track1:
    # name: track1  # By default
    pan: -1
  track2:
    name: music2
    volume: .85
  music_menu:  # Playlist "music_menu" could be default musicName value in MenuScreen
    name: [track1, track2, music3]
    isLoop: true
    volume: .75

В компонентах можно запускать звуки с фиксированными именами по умолчанию. Если их нет в ассетах, то ничего не происходит, а если есть, то они будут проиграны. Это позволит нам добавлять звуки в приложение без всякого вмешательство в код. Нужно только заранее знать имена звуков по умолчанию для каждого компонента.

class Button extends Component
{
    // Settings
    public var soundClick = "click_button";
    // ...
    private function interactiveObject_clickHandler(event:MouseEvent):Void
    {
        audioManager.playSound(soundClick);
        clickSignal.dispatch(this);
    }
}

Во внешнем yml-файле можно хранить также и переводы всех текстовых значений, использующихся в приложении. Получать переводы можно через LangManager. В нем достаточно двух методов: load() и get() и двух свойств: langs и currentLang. Не считая, естественно, словарей с переводами.

Поддержку переводов можно внедрить в существующее приложение совершенно незаметно, если вы уже использовали для всех текстовых полей отдельный компонент (Label). Пусть даже он ничего и не делал, кроме как парсил текстовое поле (TextField) и задавал ему значение (textField.text = new UTF8String(text);). Нам необходимо лишь добавить в этот компонент поддержку LangManager и создать файл с переводами:

class Label extends Component
{
    // State
    // private var langManager:LangManager; // Defined in Component
    private var textField:TextField;
    public var text(default, set):String;
    public function set_text(value:String):String
    {
        if (text != value)
        {
            text = value;
            refreshText();
        }
        return value;
    }
    public var actualText(default, null):String;

    override private function assignSkin():Void
    {
        super.assignSkin();
				textField = Std.downcast(skin, TextField);
        langManager.currentLangChangeSignal.add(langManager_currentLangChangeSignalHandler);
				refreshText();
    }
    override private function unassignSkin():Void
    {
        langManager.currentLangChangeSignal.remove(lang_currentLangChangeSignalHandler);
        textField = null;
        super.unassignSkin();
    }
    private function refreshText():Void
    {
        actualText = langManager.get(text);
        if (textField != null && actualText != null)
        {
            textField.text = new UTF8String(actualText);
        }
    }
    private function langManager_currentLangChangeSignalHandler(currentLang:String):Void
    {
        refreshText();
    }
}

В качестве ключей для переводов можно использовать обычный текст, который задается в коде по умолчанию (в коде: label.text = "Sound volume:"; в yml: "Sound volume:": "Громкость звука:"):

currentLang: en
langs: ["en", "ru", "de"]
en:
    Dressing: Dress Up  # Fix hardcoded text
ru:
    Managers: Менеджеры
    Dressing: Одевалка
    "Sound volume:": "Громкость звука:"
    "Music volume:": "Громкость музыки:"

Если какой-нибудь текст вдруг нужно исправить, не обязательно лезть в код. Можно просто сделать "перевод" на тот же язык ("Dressing": "Dress Up"). Если хочется использовать явные ключи, то их можно визуально отделить от обычного текста добавлением какого-нибудь особого символа в начало ключа, например, "@":

en:
    "@menu_title": Managers
ru:
    "@menu_title": Менеджеры

Для переключения языков можно также сделать специальный компонент LangButton, наследующий от RadioButton. А также LangPanel, которая будет брать данные о языках в LangManager, скрывать лишние LangButton, а для остальных — задавать свойство langButton.language = langManager.langs[i];.

Application

Создание скринов и настройку всех менеджеров можно вынести в класс Application. Это позволит нам не повторять одни и те же операции в Main-классах каждого приложения. От Application будут наследоваться в дальнейшем все Main-классы.

class Application extends Sprite
{
    // Settings
    private var startScreenClass:Class<Screen>;

    public function new()
    {
        super();
        init();
    }
    private function init():Void
    {
        var ioc = new IoC();
        configureApp();
        var langManager = ioc.getSingleton(LangManager);
        var audioManager = ioc.getSingleton(AudioManager);
        langManager.load();
        audioManager.load();
        var screens = ioc.getSingleton(Screens);
        screens.skin = this;
        screens.open(startScreenClass);
    }
    // Override
    private function configureApp():Void
    {
    }
}
class Main extends Application
{
}

Выводы

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

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

Исходники

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

Комментарии (0)