В первой части этого руководства мы создали сцену 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();
},
На этом мы закончили с третьей частью урока. Несмотря на то, что мы проделали большую работу, наша игра требует гораздо больше усилий, чтобы быть полностью функциональной. Как упражнение попробуйте добавить сохранение состояние персонажей игрока и заставить их получать опыт и уровни. Также вы можете добавить дополнительные действия в меню «Действия» и передать их вместе с событиями.