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

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

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

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

Тайлы созданы Kenney Vleugels и могут быть скачены на www.kenney.nl

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

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

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

Но сперва немного реорганизуем наш код и разобьём наш Javascript-код на три логические части:

<script src="js/world.js"></script>       
<script src="js/battle.js"></script>
<script src="js/start.js"></script>

Наш стартовый файл будет содержать конфигурацию, в которую добавим сцены BattleScene и UIScene, и запуск игры:

var config = {
    type: Phaser.AUTO,
    parent: 'content',
    width: 320,
    height: 240,
    zoom: 2,
    pixelArt: true, //чтобы не было размытия текстур при масштабировании
    physics: {
        default: 'arcade', //данный режим позволит перемещать персонажа
        arcade: {
            gravity: { y: 0 },
            debug: false // установите в true, чтобы видеть зоны
        }
    },
    scene: [
        BootScene,
        WorldScene,
        BattleScene,
        UIScene
    ]
};
var game = new Phaser.Game(config);

Все остальное из первого файла перенесем в файл word.js. А вот и наши упрощенные BattleScene и UIScenes. Мы будем использовать их, чтобы показать, как будет работать переключение сцен:

var BattleScene = new Phaser.Class({

    Extends: Phaser.Scene,

    initialize:

    function BattleScene ()
    {
        Phaser.Scene.call(this, { key: 'BattleScene' });
    },
    create: function ()
    {
        // // меняем фон на зеленый
        this.cameras.main.setBackgroundColor('rgba(0, 200, 0, 0.5)');
        // // Одновременно запускаем сцену UI Scene
        this.scene.run('UIScene');
    }
});

var UIScene = new Phaser.Class({

    Extends: Phaser.Scene,

    initialize:

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

    create: function ()
    {       
        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);
    }
});

Теперь нам нужно изменить WorldScene, чтобы запустить BattleScene. Измените WorldScene onMeetEnemy следующим образом:

onMeetEnemy: function(player, zone) {        
        // мы перемещаем зону в другое место
        zone.x = Phaser.Math.RND.between(0, this.physics.world.bounds.width);
        zone.y = Phaser.Math.RND.between(0, this.physics.world.bounds.height);

       // встряхиваем мир
        this.cameras.main.shake(300);
       // this.cameras.main.flash(300);

        // начало боя
        // переключаемся на  BattleScene
        this.scene.switch('BattleScene');
},

Если вы запустите игру сейчас, вы увидите, что когда игрок встречается с невидимым врагом, то запускается BattleScene. А BattleScene будет контролировать UIScene. Давайте посмотрим, как вернуться к WorldScene. Теперь создадим метод exitBattle в BattleScene :

exitBattle: function() {
        this.scene.sleep('UIScene');
        this.scene.switch('WorldScene');
},

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

var timeEvent = this.time.addEvent({delay: 2000, callback: this.exitBattle, callbackScope: this});

Теперь у нас появилась проблема. Все работает только при первом переключении на BattleScene. Во второй раз ее функция create не вызывается, поэтому у нас не отображается UIScene, и мы не покидаем BattleScene по истечении заданного времени. Чтобы это исправить, нам нужно послушать событие «Пробуждения» сцены. Добавьте этот код в конце функции create BattleScene:

this.sys.events.on('wake', this.wake, this);

Нам нужно добавить функцию wake. Она запустит UIScene и добавит синхронизированное событие для выхода из BattleScene:

wake: function() {
        this.scene.run('UIScene');  
        this.time.addEvent({delay: 2000, callback: this.exitBattle, callbackScope: this});        
},

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

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

// враги
this.load.image("dragonblue", "assets/dragonblue.png");
this.load.image("dragonorrange", "assets/dragonorrange.png");

Также добавим все остальные классы из второго урока: Unit, Enemy, PlayerCharacter, UIScene, MenuItem, Menu, HeroesMenu, ActionsMenu, EnemiesMenu и Message. Пришло время изменить в файл battle.js класс Unit следующим образом:

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; // урон по умолчанию     
        this.living = true;         
        this.menuItem = null;
    },
    // мы будем использовать эту функцию, чтобы установить пункт меню, когда юнит умирает
    setMenuItem: function(item) {
        this.menuItem = item;
    },
    // атака целевого юнита
    attack: function(target) {
        if(target.living) {
            target.takeDamage(this.damage);
            this.scene.events.emit("Message", this.type + " атакует " + target.type + " с " + this.damage + " уроном");
        }
    },    
    takeDamage: function(damage) {
        this.hp -= damage;
        if(this.hp <= 0) {
            this.hp = 0;
            this.menuItem.unitKilled();
            this.living = false;
            this.visible = false;   
            this.menuItem = null;
        }
    }    
});

Здесь мы ввели свойство - переменную menuItem. Мы свяжем каждый юнит с его пунктом меню, и когда он будет убит, то уведомит об этом пункт меню, поэтому игрок не сможет выбрать убитого врага. Кроме того, мы добавили свойство - living. Мы будем использовать его, чтобы проверить, жив ли текущий юнит. Только живые существа смогут участвовать в битве.

Теперь нам нужно отредактировать следующий круг атаки юнитов - метод nextTurn, чтобы учесть это новое свойство. Измените метод nextTurn BattleScene следующим образом:

nextTurn: function() {        
   do {
         this.index++;
         // если юнитов больше нет, то начинаем сначала с первого
         if(this.index >= this.units.length) {
             this.index = 0;
         }
     } while(!this.units[this.index].living);

     // если это герой игрока
     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 });
     }
},

Здесь мы увеличиваем index, пока не получим живой юнит. Мертвые юниты больше не смогут двигаться. Но сейчас у нас проблема. У нас будет бесконечный цикл, если не будет живых единиц. Чтобы избежать этого, мы должны проверить на проигрыш или победу.

Добавьте этот код вначале nextTurn:

//проверяем не настал ли уже проигрыш или победа
if(this.checkEndBattle()) {           
    this.endBattle();
    return;
}  

А вот как должен выглядеть checkEndBattle:

//проверка на проигрыш или победу
checkEndBattle: function() {        
        var victory = true;
        // если все враги умерли - мы победили
        for(var i = 0; i < this.enemies.length; i++) {
            if(this.enemies[i].living)
                victory = false;
        }
        var gameOver = true;
        // если все герои умерли - мы проиграли
        for(var i = 0; i < this.heroes.length; i++) {
            if(this.heroes[i].living)
                gameOver = false;
        }
        return victory || gameOver;
},

А вот и конец боя:

endBattle: function() {       
        // очищаем состояния, удаляем спрайты
        this.heroes.length = 0;
        this.enemies.length = 0;
        for(var i = 0; i < this.units.length; i++) {
            // ссылки на экземпляры юнитов
            this.units[i].destroy();            
        }
        this.units.length = 0;
        // скрываем UI
        this.scene.sleep('UIScene');
        // возвращаемся в WorldScene и скрываем BattleScene
        this.scene.switch('WorldScene');
},

Финальная версия nextTurn:

nextTurn: function() {
        //проверяем не настал ли уже проигрыш или победа
        if(this.checkEndBattle()) {           
            this.endBattle();
            return;
        }        
        do {
            this.index++;
            // если юнитов больше нет, то начинаем сначала с первого
            if(this.index >= this.units.length) {
                this.index = 0;
            }
        } while(!this.units[this.index].living);

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

Перенесем часть логики из метода create сцены BattleScene в метод startBattle:

startBattle: function() {
         // персонаж игрока - 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');

        this.index = -1; // текущий активный юнит     
},

Как мы видели в простой версии BattleScene, нам нужно слушать событие wake. Таким образом, метод create будет выглядеть так:

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

        this.startBattle();
        // вешаем на событие wake вызов метода startBattle
        this.sys.events.on('wake', this.startBattle, this);
    },

Теперь мы изменим класс MenuItem, добавив метод unitKilled:

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');
    },
    // когда связанный враг или игрок убит
    unitKilled: function() {
        this.active = false;
        this.visible = false;
    }
 });

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

moveSelectionUp: function() {
        this.menuItems[this.menuItemIndex].deselect();
        do {
            this.menuItemIndex--;
            if(this.menuItemIndex < 0)
                this.menuItemIndex = this.menuItems.length - 1;
        } while(!this.menuItems[this.menuItemIndex].active);
        this.menuItems[this.menuItemIndex].select();
},
moveSelectionDown: function() {
        this.menuItems[this.menuItemIndex].deselect();
        do {
            this.menuItemIndex++;
            if(this.menuItemIndex >= this.menuItems.length)
                this.menuItemIndex = 0;
        } while(!this.menuItems[this.menuItemIndex].active);
        this.menuItems[this.menuItemIndex].select();
},

Нам также нужно изменить метод select, чтобы активное меню выбирало только активные элементы:

select: function(index) {
        if(!index)
            index = 0;       
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex = index;
        while(!this.menuItems[this.menuItemIndex].active) {
            this.menuItemIndex++;
            if(this.menuItemIndex >= this.menuItems.length)
                this.menuItemIndex = 0;
            if(this.menuItemIndex == index)
                return;
        }        
        this.menuItems[this.menuItemIndex].select();
        this.selected = true;
},

Вот тут стоит изменить во всем проекте название события SelectEnemies на SelectedAction, т.к. это событие связано с меню, а не с врагами. Аналогично стоит изменить название вызываемого метода на onSelectedAction. Вы можете продолжить со старым именем или использовать новое. Лучший способ - выбрать пункт "заменить все", и ваша ide сделает все автоматически. Т.к. событие вызывается, когда игрок выбрает действие, то код теперь немного более понятен. Нам также нужно изменить метод пересоздания меню remap:

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

В этот метод добавим связывание юнита и пункта соответствующего меню.

Кроме того, изменим метод addMenuItem, чтобы он возвращал созданный пункт меню:

addMenuItem: function(unit) {
        var menuItem = new MenuItem(0, this.menuItems.length * 20, unit, this.scene);
        this.menuItems.push(menuItem);
        this.add(menuItem); 
        return menuItem;
},

Теперь мы почти закончили с изменениями. Осталось исправить UIScene и WorldScene для прослушивания события wake. Начнем с UIScene. Код, отвечающий за создание глобального меню, останется в методе create, но код, отвечающий за конкретную битву, перейдет к новой функции. Измените метод create UIScene на этот:

create: function ()
    {    
        // рисуем фон для меню
        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);

        // основной контейнер для хранения всех меню
        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);

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

        // слушаем события от клавиатуры
        this.input.keyboard.on("keydown", this.onKeyInput, this);   

        // когда наступает ход игрока
        this.battleScene.events.on("PlayerSelect", this.onPlayerSelect, this);

        // когда выбрано действие в меню
        // на данный момент у нас есть только одно действие, поэтому мы не отправляем id действия
        this.events.on("SelectedAction", this.onSelectedAction, this);

        // когда выбран враг
        this.events.on("Enemy", this.onEnemy, this);

        // когда сцена получает событие wake
        this.sys.events.on('wake', this.createMenu, this);

        // сообщение, описывающее текущее действие
        this.message = new Message(this, this.battleScene.events);
        this.add.existing(this.message);        

        this.createMenu();     
},

Как видите, мы вызываем метод createMenu при первом запуске UIScene и при его запуске через событие wake. Вот наш метод createMenu:

createMenu: function() {
        // перестроение пунктов меню для героев
        this.remapHeroes();
        // перестроение пунктов меню для врагов
        this.remapEnemies();
        // первый шаг
        this.battleScene.nextTurn(); 
},

После запуска игры вы можете найти проблему: если игрок на очередном ходу нажимает несколько раз пробел при выборе врага, то метод nextTurn вызывается несколько раз, и вы можете наблюдать странное поведение. Чтобы это исправить, мы должны быть уверены, что отправляем только одно событие в меню. Для этого добавить свойство selected в класс Menu. Когда Меню получает фокус, это свойство становится истинным. Когда игрок выбирает действие, оно становится ложным, и в меню больше не будет вызываться метод действия. Вот как должен выглядеть класс 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.x = x;
        this.y = y;        
        this.selected = false;
    },     
    addMenuItem: function(unit) {
        var menuItem = new MenuItem(0, this.menuItems.length * 20, unit, this.scene);
        this.menuItems.push(menuItem);
        this.add(menuItem); 
        return menuItem;
    },  
    // навигация по меню
    moveSelectionUp: function() {
        this.menuItems[this.menuItemIndex].deselect();
        do {
            this.menuItemIndex--;
            if(this.menuItemIndex < 0)
                this.menuItemIndex = this.menuItems.length - 1;
        } while(!this.menuItems[this.menuItemIndex].active);
        this.menuItems[this.menuItemIndex].select();
    },
    moveSelectionDown: function() {
        this.menuItems[this.menuItemIndex].deselect();
        do {
            this.menuItemIndex++;
            if(this.menuItemIndex >= this.menuItems.length)
                this.menuItemIndex = 0;
        } while(!this.menuItems[this.menuItemIndex].active);
        this.menuItems[this.menuItemIndex].select();
    },
    // выбрать меню целиком и подсветить текущий элемент
    select: function(index) {
        if(!index)
            index = 0;       
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex = index;
        while(!this.menuItems[this.menuItemIndex].active) {
            this.menuItemIndex++;
            if(this.menuItemIndex >= this.menuItems.length)
                this.menuItemIndex = 0;
            if(this.menuItemIndex == index)
                return;
        }        
        this.menuItems[this.menuItemIndex].select();
        this.selected = true;
    },
    // отменить выбор этого меню
    deselect: function() {        
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex = 0;
        this.selected = false;
    },
    confirm: function() {
        // что делать, когда игрок подтверждает свой выбор
    },
    // очищаем меню и удаляем все пункты
    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];
            unit.setMenuItem(this.addMenuItem(unit.type));            
        }
        this.menuItemIndex = 0;
    }
});

А вот так мы изменим метод onKeyInput в UIScene :

onKeyInput: function(event) {
        if(this.currentMenu && this.currentMenu.selected) {
            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 в WorldScene, персонаж не стоит, а движется в каком-то направлении, и вам нужно нажать и отпустить клавишу этого направления, чтобы остановить его. Это небольшая ошибка, но мы должны это исправить. Нам нужно дождаться события пробуждения (wake) WorldScene, а затем сбросить ключи.

Добавьте эту строку в конец функции create WorldScene:

this.sys.events.on('wake', this.wake, this);

А вот как должен выглядеть метод wake:

wake: function() {
        this.cursors.left.reset();
        this.cursors.right.reset();
        this.cursors.up.reset();
        this.cursors.down.reset();
},

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

Оригинал