Как создать пошаговую RPG в Phaser 3 (часть 2)

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

  • Управление сценами в Phaser 3
  • Обработка ввода с клавиатуры для навигации по пользовательскому интерфейсу
  • Использование пользовательских событий
  • Наследование классов в Phaser 3
  • Создание базовой логики боевой сцены
  • Использование таймера

    Вы можете скачать исходный код учебника здесь.

Все assets, используемые в этом руководстве, имеют лицензию CC0.

Персонажи игроков - https://opengameart.org/content/rpg-character-sprites.

Враги - https://opengameart.org/content/dragon-1.

Мы начнем с пустой игры, а позже объединяем ее с кодом из первой части. Создадим две сцены: BattleScene, где будут сражаться игроки, и UIScene, отвечающая за пользовательский интерфейс.

var BootScene = new Phaser.Class({

    Extends: Phaser.Scene,

    initialize:

    function BootScene ()
    {
        Phaser.Scene.call(this, { key: 'BootScene' });
    },

    preload: function ()
    {
        // load resources
        this.load.spritesheet('player', 'assets/RPG_assets.png', { frameWidth: 16, frameHeight: 16 });
        this.load.image('dragonblue', 'assets/dragonblue.png');
        this.load.image('dragonorrange', 'assets/dragonorrange.png');
    },

    create: function ()
    {
        this.scene.start('BattleScene');
    }
});

var BattleScene = new Phaser.Class({

    Extends: Phaser.Scene,

    initialize:

    function BattleScene ()
    {
        Phaser.Scene.call(this, { key: 'BattleScene' });
    },
    create: function ()
    {
        // Одновременно запускаем сцену UI Scene 
        this.scene.launch('UIScene');
    }
});

var UIScene = new Phaser.Class({

    Extends: Phaser.Scene,

    initialize:

    function UIScene ()
    {
        Phaser.Scene.call(this, { key: 'UIScene' });
    },

    create: function ()
    {    

    }
});

var config = {
    type: Phaser.AUTO,
    parent: 'content',
    width: 320,
    height: 240,
    zoom: 2,
    pixelArt: true,
    physics: {
        default: 'arcade',
        arcade: {
            gravity: { y: 0 }
        }
    },
    scene: [ BootScene, BattleScene, UIScene ]
};

var game = new Phaser.Game(config);

В приведенном выше коде наиболее интересной частью является метод создания BattleScene. Здесь мы используем для запуска UIScene не метод scene.start, а scene.launch. Если вы запустите игру сейчас, вы не увидите ничего особенного, но имейте в виду, что обе сцены активны одновременно. Чтобы лучше это представить, мы добавим графический объект в сцену пользовательского интерфейса и нарисуем простой фон для интерфейса. Сначала добавьте эту строку в метод create BattleScene:

this.cameras.main.setBackgroundColor('rgba(0, 200, 0, 0.5)');

Это простой способ сделать фон сцены зеленым, не добавляя к нему фактическое изображение. Далее добавьте этот код в метод create UIScene:

this.graphics = this.add.graphics();
this.graphics.lineStyle(1, 0xffffff);
this.graphics.fillStyle(0x031f4c, 1);        
this.graphics.strokeRect(2, 150, 90, 100);
this.graphics.fillRect(2, 150, 90, 100);
this.graphics.strokeRect(95, 150, 90, 100);
this.graphics.fillRect(95, 150, 90, 100);
this.graphics.strokeRect(188, 150, 130, 100);
this.graphics.fillRect(188, 150, 130, 100);

Если вы запустите игру сейчас, вы увидите зеленый фон BattleScene и три синих прямоугольника UIScene: Теперь нам нужно создать концепцию для юнитов - как врагов, так и героев игроков. Мы создадим базовый класс Unit следующим образом (добавьте этот код где-нибудь за пределами кода Scenes, например в верхней части проекта):

var Unit = new Phaser.Class({
    Extends: Phaser.GameObjects.Sprite,

    initialize:

    function Unit(scene, x, y, texture, frame, type, hp, damage) {
        Phaser.GameObjects.Sprite.call(this, scene, x, y, texture, frame)
        this.type = type;
        this.maxHp = this.hp = hp;
        this.damage = damage; // урон по умолчанию                
    },
    attack: function(target) {
        target.takeDamage(this.damage);      
    },
    takeDamage: function(damage) {
        this.hp -= damage;        
    }
});

А теперь мы создадим врага следующим образом:

var Enemy = new Phaser.Class({
    Extends: Unit,

    initialize:
    function Enemy(scene, x, y, texture, frame, type, hp, damage) {
        Unit.call(this, scene, x, y, texture, frame, type, hp, damage);
    }
});

И юнита игрока:

var PlayerCharacter = new Phaser.Class({
    Extends: Unit,

    initialize:
    function PlayerCharacter(scene, x, y, texture, frame, type, hp, damage) {
        Unit.call(this, scene, x, y, texture, frame, type, hp, damage);
        // зеркально развернем изображение, чтобы не править его в ручную
        this.flipX = true;

        this.setScale(2);
    }
});

Так как лениво править спрайт-лист, чтобы в нем были спрайты персонажей, изображенные слева, то повернем их используя свойство flipX объекта Phaser3 Sprite. В нашей первой битве мы жестко закодируем как героев игроков, так и вражеских драконов. В следующей части этого урока мы создадим их в соответствии с ходом игры. Измените метод создания BattleScene следующим образом:

create: function ()
    {
        // меняем фон на зеленый
        this.cameras.main.setBackgroundColor('rgba(0, 200, 0, 0.5)');

        // персонаж игрока - warrior (воин)
        var warrior = new PlayerCharacter(this, 250, 50, 'player', 1, 'Воин', 100, 20);        
        this.add.existing(warrior);

        // персонаж игрока - mage (маг)
        var mage = new PlayerCharacter(this, 250, 100, 'player', 4, 'Маг', 80, 8);
        this.add.existing(mage);            

        var dragonblue = new Enemy(this, 50, 50, 'dragonblue', null, 'Дракон', 50, 3);
        this.add.existing(dragonblue);

        var dragonOrange = new Enemy(this, 50, 100, 'dragonorrange', null,'Дракон2', 50, 3);
        this.add.existing(dragonOrange);

        // массив с героями
        this.heroes = [ warrior, mage ];
        // массив с врагами
        this.enemies = [ dragonblue, dragonOrange ];
        // массив с обеими сторонами, которые будут атаковать
        this.units = this.heroes.concat(this.enemies);

        // Одновременно запускаем сцену UI Scene 
        this.scene.launch('UIScene');
    }

Теперь, если вы запустите игру, вы должны увидеть что-то вроде этого: Пришло время добавить пользовательский интерфейс. У нас будет три меню - меню героев, меню врагов и меню действий. Все они будут потомками общего класса Menu. Класс Menu будет контейнером для объектов MenuItem, и мы будем использовать Phaser.GameObjects.Container в качестве его базового класса.

Начнем с класса MenuItem. Он расширит класс Phaser.GameObjects.Text и будет иметь только два метода: select (выбрать) и deselect (отменить выбор). Первый из них сделает текст желтым, а второй - белым.

var MenuItem = new Phaser.Class({
    Extends: Phaser.GameObjects.Text,

    initialize:

    function MenuItem(x, y, text, scene) {
        Phaser.GameObjects.Text.call(this, scene, x, y, text, { color: '#ffffff', align: 'left', fontSize: 15});
    },

    select: function() {
        this.setColor('#f8ff38');
    },

    deselect: function() {
        this.setColor('#ffffff');
    }

});

Теперь нам нужно создать класс Menu. Это будет немного сложнее. Этому классу необходимы методы для выбора пункта и отмены выбора всех пунктов (например, когда игроку нужно выбрать противника для атаки, выбирается все меню «Враги»). Также нужны методы для добавления пунктов меню.

var Menu = new Phaser.Class({
    Extends: Phaser.GameObjects.Container,

    initialize:

    function Menu(x, y, scene, heroes) {
        Phaser.GameObjects.Container.call(this, scene, x, y);
        this.menuItems = [];
        this.menuItemIndex = 0;
        this.heroes = heroes;
        this.x = x;
        this.y = y;
    },     
    addMenuItem: function(unit) {
        var menuItem = new MenuItem(0, this.menuItems.length * 20, unit, this.scene);
        this.menuItems.push(menuItem);
        this.add(menuItem);        
    },            
    moveSelectionUp: function() {
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex--;
        if(this.menuItemIndex < 0)
            this.menuItemIndex = this.menuItems.length - 1;
        this.menuItems[this.menuItemIndex].select();
    },
    moveSelectionDown: function() {
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex++;
        if(this.menuItemIndex >= this.menuItems.length)
            this.menuItemIndex = 0;
        this.menuItems[this.menuItemIndex].select();
    },
    // выбрать меню целиком и элемент с индексом в нем
    select: function(index) {
        if(!index)
            index = 0;
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex = index;
        this.menuItems[this.menuItemIndex].select();
    },
    // отменить выбор этого меню
    deselect: function() {        
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex = 0;
    },
    confirm: function() {
        // что делать, когда игрок подтверждает свой выбор
    }   
});

Теперь мы создадим все отдельные меню:

var HeroesMenu = new Phaser.Class({
    Extends: Menu,

    initialize:

    function HeroesMenu(x, y, scene) {
        Menu.call(this, x, y, scene);                    
    }
});

var ActionsMenu = new Phaser.Class({
    Extends: Menu,

    initialize:

    function ActionsMenu(x, y, scene) {
        Menu.call(this, x, y, scene);   
        this.addMenuItem('Атака');
    },
    confirm: function() {
        //  что делать, когда игрок выбирает действие
    }

});

var EnemiesMenu = new Phaser.Class({
    Extends: Menu,

    initialize:

    function EnemiesMenu(x, y, scene) {
        Menu.call(this, x, y, scene);        
    },       
    confirm: function() {        
        // do something when the player selects an enemy
    }
});

А теперь нам нужно добавить меню в сцену UIScene. Добавьте этот код внизу метода create UIScene:

// основной контейнер для хранения всех меню
this.menus = this.add.container();

this.heroesMenu = new HeroesMenu(195, 153, this);           
this.actionsMenu = new ActionsMenu(100, 153, this);            
this.enemiesMenu = new EnemiesMenu(8, 153, this);   

// текущее выбранное меню
this.currentMenu = this.actionsMenu;

// добавление меню в контейнер
this.menus.add(this.heroesMenu);
this.menus.add(this.actionsMenu);
this.menus.add(this.enemiesMenu);

Теперь вы увидите, что в контейнере есть только меню действий (потому что мы жестко закодировали действие Атака). HeroesMenu и EnemiesMenu оба пусты. Нам нужно получить данные для них из BattleScene. Чтобы получить доступ к BattleScene из UIScene, нам нужно добавить следующий код в метод create:

this.battleScene = this.scene.get('BattleScene');

Сначала мы изменим меню: добавим функциональность для очистки всех пунктов и добавления новых пунктов. Первый метод будет назван clear и удалит все пункты меню из массива menuItems. Второй метод получает массив пунктов и добавляет их в MenuItems с помощью метода addMenuItem. Добавьте эти два метода в класс Menu:

clear: function() {
    for(var i = 0; i < this.menuItems.length; i++) {
        this.menuItems[i].destroy();
    }
    this.menuItems.length = 0;
    this.menuItemIndex = 0;
},
remap: function(units) {
    this.clear();        
    for(var i = 0; i < units.length; i++) {
        var unit = units[i];
        this.addMenuItem(unit.type);
     }
}

И нам нужны методы для вызова этой функции для меню. Добавьте этот код в UIScene:

remapHeroes: function() {
    var heroes = this.battleScene.heroes;
    this.heroesMenu.remap(heroes);
},
remapEnemies: function() {
    var enemies = this.battleScene.enemies;
    this.enemiesMenu.remap(enemies);
},

И нам нужно вызвать эти функции. Добавьте их в конце метода create UIScene:

this.remapHeroes();
this.remapEnemies();

Теперь ваша игра должна выглядеть так: Но наша игра слишком статична и нам нужно заставить его двигаться, поэтому далее мы сделаем обработку ввода от пользователя. Игрок будет перемещаться по меню с помощью клавиш со стрелками и будет выбирать пункт в меню, нажав пробел. Чтобы отслеживать события клавиатуры, добавьте эту строку внизу метода create UIScene:

this.input.keyboard.on('keydown', this.onKeyInput, this);

Теперь нам нужно добавить метод onKeyInput в UIScene:

onKeyInput: function(event) {

},

У нас будет активное меню currentMenu, и все команды будут выполняться над ним. Итак, давайте напишем тело функции onKeyInput следующим образом:

onKeyInput: function(event) {
    if(this.currentMenu) {
        if(event.code === "ArrowUp") {
            this.currentMenu.moveSelectionUp();
        } else if(event.code === "ArrowDown") {
            this.currentMenu.moveSelectionDown();
        } else if(event.code === "ArrowRight" || event.code === "Shift") {

        } else if(event.code === "Space" || event.code === "ArrowLeft") {
            this.currentMenu.confirm();
        } 
    }
},

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

Добавьте эту строку в конце метода create BattleScene:

this.index = -1;

Этот индекс хранит текущий активный юнит в массиве юнитов. Теперь нам нужна функция nextTurn. Добавьте этот код в BattleScene:

nextTurn: function() {
    this.index++;
    // если юнитов больше нет, то начинаем сначала с первого
    if(this.index >= this.units.length) {
        this.index = 0;
    }
    if(this.units[this.index]) {
        // если это герой игрока
        if(this.units[this.index] instanceof PlayerCharacter) {                
            this.events.emit('PlayerSelect', this.index);
        } else { // иначе если это юнит врага
            // выбираем случайного героя
            var r = Math.floor(Math.random() * this.heroes.length);
            // и вызываем функцию атаки юнита врага 
            this.units[this.index].attack(this.heroes[r]);  
            // добавляем задержку на следующий ход, чтобы был плавный игровой процесс 
            this.time.addEvent({ delay: 3000, callback: this.nextTurn, callbackScope: this });
        }
    }
},

Здесь мы вызываем пользовательское событие PlayerSelect. Мы будем слушать его в UIScene. Другой интересной частью здесь является то, что после атаки вражеского отряда мы используем задержку в 3 секунды для вызова nextTurn. Таким образом, у нас будет время, чтобы увидеть, что происходит на экране.

В Phaser3 вы можете прослушивать события из одной сцены в другой сцене. Добавьте эту строку внизу метода create UIScene для прослушивания события PlayerSelect:

this.battleScene.events.on("PlayerSelect", this.onPlayerSelect, this);

И тогда нам нужно создать метод onPlayerSelect UIScene.

onPlayerSelect: function(id) {
        this.heroesMenu.select(id);
        this.actionsMenu.select(0);
        this.currentMenu = this.actionsMenu;
},

Мы просто выбираем id-й элемент из heroesMenu. Затем мы выбираем первый элемент в ActionsMenu, и он становится текущим активным меню. Теперь нам нужно добавить методы подтверждения в меню. Пользователь сначала будет взаимодействовать с меню действий, затем он подтвердит свой выбор пробелом, а после он должен выбрать противника для выполнения действия (атаки). Когда враг выбран, нам нужно сообщить об этом в BattleScene.

Теперь измените ActionsMenu:

var ActionsMenu = new Phaser.Class({
    Extends: Menu,

    initialize:

    function ActionsMenu(x, y, scene) {
        Menu.call(this, x, y, scene);   
        this.addMenuItem('Attack');
    },
    confirm: function() {      
        this.scene.events.emit('SelectEnemies');        
    }

});

После подтверждения мы вызовем пользовательское событие SelectEnemies. Теперь добавьте этот код в конец метода create UIScene:

this.events.on("SelectEnemies", this.onSelectEnemies, this);

А теперь нам нужно создать метод onSelectEnemies в UIScene:

onSelectEnemies: function() {
        this.currentMenu = this.enemiesMenu;
        this.enemiesMenu.select(0);
},

Мы просто активируем меню врагов и выбираем первого врага.

Теперь измените метод confirm EnemiesMenu следующим образом:

confirm: function() {        
        this.scene.events.emit("Enemy", this.menuItemIndex);
}

А затем добавьте эту строку внизу метода create UIScene:

this.events.on("Enemy", this.onEnemy, this);

А метод onEnemy UIScene очистит все меню и отправит данные в BattleScene:

onEnemy: function(index) {
    this.heroesMenu.deselect();
    this.actionsMenu.deselect();
    this.enemiesMenu.deselect();
    this.currentMenu = null;
    this.battleScene.receivePlayerSelection('attack', index);
},

Мы уже почти закончили. Мы начнем первый ход с метода создания UIScene. Таким образом, мы подготовим обе сцены перед началом боя. Добавьте эту строку внизу метода create UIScene:

this.battleScene.nextTurn();

Вы можете немного поиграть с игрой заметить, что выбор от игрока не передается в BattleScene.

Добавьте этот метод в BattleScene:

receivePlayerSelection: function(action, target) {
    if(action == 'attack') {            
        this.units[this.index].attack(this.enemies[target]);              
    }
    this.time.addEvent({ delay: 3000, callback: this.nextTurn, callbackScope: this });        
},

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

var Message = new Phaser.Class({

    Extends: Phaser.GameObjects.Container,

    initialize:
    function Message(scene, events) {
        Phaser.GameObjects.Container.call(this, scene, 160, 30);
        var graphics = this.scene.add.graphics();
        this.add(graphics);
        graphics.lineStyle(1, 0xffffff, 0.8);
        graphics.fillStyle(0x031f4c, 0.3);        
        graphics.strokeRect(-90, -15, 180, 30);
        graphics.fillRect(-90, -15, 180, 30);
        this.text = new Phaser.GameObjects.Text(scene, 0, 0, "", { color: '#ffffff', align: 'center', fontSize: 13, wordWrap: { width: 160, useAdvancedWrap: true }});
        this.add(this.text);
        this.text.setOrigin(0.5);        
        events.on("Message", this.showMessage, this);
        this.visible = false;
    },
    showMessage: function(text) {
        this.text.setText(text);
        this.visible = true;
        if(this.hideEvent)
            this.hideEvent.remove(false);
        this.hideEvent = this.scene.time.addEvent({ delay: 2000, callback: this.hideMessage, callbackScope: this });
    },
    hideMessage: function() {
        this.hideEvent = null;
        this.visible = false;
    }
});

Мы добавим объект сообщения в UIScene, поскольку он является частью интерфейса. Добавьте эти строки к методу create UIScene:

this.message = new Message(this, this.battleScene.events);
this.add.existing(this.message);

И на этом вторая часть урока завершена. Ознакомьтесь с третьей частью, чтобы узнать, как объединить WorldScene и BattleScene в рабочую игру.

Оригинал