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

Эволюция игрового фреймворка. Клиент 1. Логика отображения

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

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

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

Все примеры реализованы на Haxe + OpenFL, но код должен быть понятен и всем тем, кто знаком с семейством языков ECMAScript. Главное тут не код, а те идеи, которые за ним лежат.

Связка Haxe + OpenFL выбрана по трем основным причинам:

  • кросс-платформенность (HTML5, iOS, Android, Linux, Windows),

  • автоматизация работы с графикой (вся графика готовится дизайнерами в Adobe Animate, и подключается в готовом виде как swf),

  • привычный Flash API (OpenFL).

Существует также TypeScript-версия OpenFL, поэтому весь код можно без особого труда конвертировать в TypeScript. Правда при этом потеряется поддержка всех платформ кроме Web.

Простейшая реализации простейшей игры

В другом месте мы уже нашли простейший возможный жанр игр — т.н. одевалка (Dress-Up). В ней нет ни правил, ни цели — одно только отображение одежек и их переключение по клику. Идеальный вариант для использования в качестве примера:

class Main extends Sprite
{
    // Settings
    public var itemNamePrefix = "item";
    // State
    private var items:Array<MovieClip>;

    public function new()
    {
        super();
        // View
        // (If preload="true" in project.xml)
        var mc = Assets.getMovieClip("dresser:AssetDresserScreen");
        addChild(mc);
        items = cast resolveNamePrefix(itemNamePrefix);
        for (item in items)
        {
            item.stop();
            item.buttonMode = true;
            item.addEventListener(MouseEvent.CLICK, item_clickHandler);
        }
    }
    private function resolveNamePrefix(namePrefix:String):Array<DisplayObject>
    {
        var i = 0;
        var result = [];
        while (true)
        {
            var object = cast mc.getChildByName(namePrefix + i);
            if (i > 0 && object == null)
            {
                break;
            }
            result.push(object);
            i++;
        }
        return result;
    }
    private function item_clickHandler(event:MouseEvent):Void
    {
        var item:MovieClip = Std.downcast(event.currentTarget, MovieClip);
        var frame = item.currentFrame + 1 >= item.totalFrame ? 1 : item.currentFrame + 1;
        item.gotoAndStop(frame);
    }
}

Проще, пожалуй и не придумаешь. Разве что можно еще перенести метод resolveNamePrefix() в конструктор в виде цикла, но простота в нашем понимании еще не означает примитивность. (Простота — это скорее признак совершенства и завершенности, нежели неразвитости.)

Благодаря resolveNamePrefix() мы можем парсить неопределенное количество одежек, лишь бы их номера шли последовательно: item1, item2, ... .

Отделение логики отображения

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

class Main extends Sprite
{
    public function new()
    {
        super();
        // View
        // (If preload="true" in project.xml)
        var mc = Assets.getMovieClip("dresser:AssetDresserScreen");
        addChild(mc);
        // Logic
        new Dresser(mc);
    }
}

Как видим, объект с графикой (мувиклип mc) тут отделен от логики отображения (Dresser), оживляющей эту графику. OpenFL допускает наследование от автоматически сгенерированного класса AssetDresserScreen. В этом случае отображение и логика отображения были бы слиты в одно целое, в один класс. Но мы пропустим этот этап.

Component

Итак, класс с логикой получает ссылку на графику, парсит ее, подписывается на события к ее дочерним элементам и так далее. Ссылка передается в конструктор, а значит, если графика меняется, то нужно создавать новый объект Dresser для нее. Чтобы иметь возможность повторно использовать тот же класс логики с разными мувиклипами, реализуем свойство skin, которое при установке будет избавляться от ссылок на предыдущую графику, после чего будет парсить новую. А поскольку таким образом будут работать все классы логики, вынесем данный шаблон в отдельный базовый класс Component:

class Component
{
    public var skin(default, set):DisplayObject;
    // No need to override. Pattern: Template Method
    public function set_skin(value:DisplayObject):DisplayObject
    {
        if (skin == value)
        {
            return value;
        }
        // Unassign previous skin
        if (skin != null)
        {
            unassignSkin();
        }

        skin = value;
        // Чтобы не нужно было делать приведения к типу при каждом использовании скина в подклассах
        interactiveObject = Std.downcast(value, InteractiveObject);
        simpleButton = Std.downcast(value, SimpleButton);
        container = Std.downcast(value, DisplayObjectContainer);
        sprite = Std.downcast(value, Sprite);
        mc = Std.downcast(value, MovieClip);

        // Assign new skin
        if (skin != null)
        {
            assignSkin();
        }
        return value;
    }
    public var interactiveObject(default, null):InteractiveObject;
    public var simpleButton(default, null):SimpleButton;
    public var container(default, null):DisplayObjectContainer;
    public var sprite(default, null):Sprite;
    public var mc(default, null):MovieClip;

    public function new(?skin:MovieClip)
    {
        this.skin = skin;
    }
    // Override
    private function assignSkin():Void
    {
    }
    // Override
    private function unassignSkin():Void
    {
    }
}

Сеттер set_skin() реализован по паттерну шаблонный метод (Template method). В результате в подклассах нам не нужно помнить, как там устроен внутри set_skin(), чтобы вдруг не сломать его. Мы просто переопределяем пустые assignSkin() и unassignSkin() и ни о чем не думаем. В первом мы парсим скин, во втором — удаляем все связанные с ним ссылки:

class Dresser extends Component
{
    //...
    override private function assignSkin():Void
    {
        super.assignSkin();

        items = cast resolveNamePrefix(itemNamePrefix);
        for (item in items)
        {
            item.stop();
            item.buttonMode = true;
            item.addEventListener(MouseEvent.CLICK, item_clickHandler);
        }
    }
    override private function unassignSkin():Void
    {
        for (item in items)
        {
            item.removeEventListener(MouseEvent.CLICK, item_clickHandler);
        }
        items = null;

        super.unassignSkin();
    }
    //...
}

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

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

К одному и тому же мувиклипу можно навесить разные компоненты и тем добавить разные типы логики. Например, если у нас возникла нужда передвигать какой-то мувиклип мышкой, мы просто инстанцируем компонент Drag и помещаем ему этот мувиклип в качестве скина:

new Drag(some_mc);

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

class Drag
{
    // State
    private var stage:Stage;
    private var mouseDownX:Float;
    private var mouseDownY:Float;
    public var isDragging(default, null):Bool;

    private function assignSkin():Void
    {
        super.assignSkin();
        stage = mc.stage;
        skin.addEventListener(MouseEvent.MOUSE_DOWN, skin_mouseDownHandler);
        if (stage != null)
        {
            stage.addEventListener(MouseEvent.MOUSE_UP, stage_mouseUpHandler);
        }
    }
    // Override
    private function unassignSkin():Void
    {
        skin.removeEventListener(MouseEvent.MOUSE_DOWN, skin_mouseDownHandler);
        if (stage != null)
        {
            stage.removeEventListener(MouseEvent.MOUSE_UP, stage_mouseUpHandler);
            stage.removeEventListener(MouseEvent.MOUSE_MOVE, stage_mouseMoveHandler);
        }
        super.unassignSkin();
    }
    private function skin_mouseDownHandler(event:MouseEvent):Void
    {
        if (stage != null)
        {
            isDragging = true;
            mouseDownX = mc.parent.mouseX - mc.x;
            mouseDownY = mc.parent.mouseY - mc.y;
            stage.addEventListener(MouseEvent.MOUSE_MOVE, stage_mouseMoveHandler);
        }
    }
    private function stage_mouseUpHandler(event:MouseEvent):Void
    {
        if (stage != null)
        {
            isDragging = false;
            stage.removeEventListener(MouseEvent.MOUSE_MOVE, stage_mouseMoveHandler);
        }
    }
    private function stage_mouseMoveHandler(event:MouseEvent):Void
    {
        mc.x = mc.parent.mouseX - mouseDownX;
        mc.y = mc.parent.mouseY - mouseDownY;
    }
}

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

Создание скина

Так как компоненты могут использовать другие компоненты, быть вложенными в них, то нелишним будет хранить ссылки на все дочерние (children), а также на родительский компонент (parent). Чтобы добавлять устанавливать одновременно оба свойства одним действием нам понадобятся отдельные методы addChild() и removeChild().

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

Скин в компонентах устанавливается двумя способами. Либо мы его создаем из ресурсов (Assets.getMovieClip()), либо берем элемент из уже созданного скина родителя (resolveNamePrefix()). Для первого способа мы добавим свойство assetName, для второго — skinPath. Оба будут использоваться в методе setSkin().

Метод setSkin() должен вызываться тогда, когда в компоненте есть ссылка на родительский контейнер, куда можно добавить вновь созданную графику (если задан assetName). Поэтому разумно добавить вызов setSkin() в то место, где родительский компонент устанавливается — в сеттер set_parent(). На случай, если в этот момент в родителе еще нет скина, мы должны вызвать setSkin(), когда он появится, т.е. в assignSkin(). Вызываться метод будет уже из родителя для своих дочерних элементов:

class Component
{
    // Settings
    public var assetName:String;
    public var skinPath:String;
    //...
    public var parent(default, set):Component;
    private function set_parent(value:Component):Component
    {
        if (parent != value)
        {
            // Remove skin created by assetName
            if (assetName != null && skin != null && skin.parent != null)
            {
                skin = null;
            }
            // Set
            parent = value;
            // Create skin by assetName
            if (assetName != null && parent != null && parent.skin != null)
            {
                // (All component properties should be set by now)
                setSkin();
            }
        }
        return value;
    }
    // Methods
	  // Override
    private function assignSkin():Void
    {
        // Set skin for children using skinPath or assetName
        for (child in children.copy())
        {
            child.setSkin();
        }
    }
	  // Override
    private function unassignSkin():Void
    {
				// Clear up all children
        for (child in children.copy())
        {
          child.skin = null;
        }
        // Remove skin created by assetName
        if (assetName != null && skin.parent != null)
        {
            skin.parent.removeChild(skin);
        }
    }
    private function setSkin():Void
    {
        if (skin != null)
        {
            // Already set
            return;
        }
        if (assetName != null)
        {
            // Create by assetName (suppose all assets are already loaded)
            var mc = Assets.getMovieClip(assetName);
            if (mc != null && parent != null && parent.container != null)
            {
                parent.container.addChild(mc);
            }
            skin = mc;
        }
        else if (skinPath != null)
        {
            // Get by skinPath
            skin = resolveSkinPath(skinPath, parent.container);
        }
    }
    private function resolveSkinPath(path:String):DisplayObject
    {
        if (container == null || path == null || path == "")
        {
          	return container;
        }
        var source = container;
        var result:DisplayObject = null;
        var pathParts:Array<String> = path.split(".");
        var count = pathParts.length;
        for (i in 0...count)
        {
            if (source == null)
            {
              	return null;
            }
            var name = pathParts[i];
            result = if (name == "parent") source.parent else source.getChildByName(name);
            if (result == null)
            {
              	return null;
            }
            if (i < count - 1)
            {
              	source = Std.downcast(result, DisplayObjectContainer);
            }
        }
        return result;
    }
    private function resolveSkinPathPrefix(pathPrefix:String):Array<String>
    {
        var result = [];
        var i = 0;
        while (true)
        {
            var path = pathPrefix + i;
            var item = resolveSkinPath(path);
            if (item == null)
            {
                // No more children
                break;
            }
            // Another child is found
            result.push(path);
            i++;
        }
        return result;
    }
    private function createComponent<T>(type:Dynamic):T
    {
      	return Type.createInstance(type, []);
    }
}

Во всякой иерархии, должен обязательно существовать корневой компонент, из которого вытекают все остальные. В качестве скина добавляется ссылка на Main. Позже мы покажем, что таким корневым компонентом будет Screens с методами: open(), сменяющим основные игровые экраны, и openDialog(), показывающим диалоги. Скрины и диалоги занимают вторую ступень в иерархии после корневого элемента.

Уничтожение компонентов

Для того, чтобы уничтожить компонент, нужно удалить его из родителя и очистить ссылку на скин. Также заодно не помешает уничтожить и все дочерние компоненты. Чтобы выполнить все эти действия в один шаг, создадим метод dispose().

class Component
{
    //...
	public function new()
	{
		init();
	}
	// Override
	private function init():Void
	{
	}
    public function dispose():Void
    {
        if (parent != null)
        {
            parent.removeChild(this);
        }
        skin = null;
        for (child in children.copy())
        {
            child.dispose();
        }
    }
    //...
}

Чтобы данный экземпляр можно было использовать повторно, дочерние элементы не должны создаваться в конструкторе, так как его можно вызвать только один раз. Для этого добавляется метод init(). В результате все внутренние компоненты должны создаваться или в init(), если они не зависят от скина, или в assignSkin() — если зависят.

Дочерние компоненты созданные в assignSkin() можно автоматически удалять в unassignSkin(), если перед вызовом assignSkin() устанавливать специальный флаг isAssigningSkin=true;, а в addChild() по этому флагу добавлять в специальный массив temporaryChildren. В unassignSkin() все компоненты из этого списка будут просто уничтожены:

class Component
{
	public function set_skin(value:DisplayObject):DisplayObject
	{
		// ...
		if (skin != null)
		{
			isAssigningSkin = true;
			assignSkin();
			isAssigningSkin = false;
		}
		return value;
	}
	private var isAssigningSkin = false;
	private var temporaryChildren:Array<Component> = [];
    //...
	private function unassignSkin():Void
	{
		// ...
		// Remove children added inside assignSkin()
		for (child in temporaryChildren.copy())
		{
			//removeChild(child);
			// Suppose, components created where they added,
			// so they should be not just removed, but disposed
			child.dispose();
		}
		temporaryChildren = [];
	}
	public function addChild(child:Component):Component
	{
		// ...
		if (isAssigningSkin)
		{
			temporaryChildren.push(child);
		}
		return child;
	}
}

Выводы

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

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

Исходники

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

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

Публикации

Истории

Работа

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