В первой части этого урока мы создали персонажа перемещающегося по сцене мира. Теперь мы собираемся сделать сцену битвы, где юниты игрока будут сражаться с врагами.
Вы можете скачать исходный код учебника здесь.
Все 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
оба пусты. Нам нужно получить данные для них из BattleScen
e. Чтобы получить доступ к 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 в рабочую игру.