Добавление игрока

Этот пример будет вам прост, поскольку он сочетает в себе методы, которые мы изучили в этой и предыдущих главах. Папка для него: chapter-3/example-3-playable-dungeon или можно скачать здесь. По мере того, как наш код растет и повторно использует части предыдущих примеров, мы будем показывать только то, что изменилось и что добавилось нового. Лучше читать эти главы с открытым исходным кодом на вашем компьютере или, по крайней мере, обратиться к этому коду позже, прежде чем переходить к следующим главам.

Изображение нашего персонажа игрока берется из того же текстурного атласа, что и элементы подземелья, поэтому нам не нужно изменять функцию preload() для загрузки любого дополнительного изображения.

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

Это тот момент в нашем исходном коде, где все становится более сложным с точки зрения организации и планирования. Добавление персонажа для игрока может показаться простой задачей, но для этого нам придется реализовать множество функций, которые являются частью основной игровой механики. Это большая работа, но, разбив ее на более мелкие части, мы справимся. Ключевым шагом на пути к тому, чтобы сделать все это управляемым, является прекращение использования всего содержимого preload(), create() и update() и начать создание небольших вспомогательных модулей и классов. В этом примере мы собираемся создать несколько новых модулей, включая менеджеры (диспетчеры) хода и подземелий, а также класс персонажа.

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

Все начинается с менеджера подземелий

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

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

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

Вот обязанности менеджера подземелья :

  • Загрузка готового уровня;
  • Переназначение чисел, используемых на этом уровне, на тайлы из нашего текстурного атласа;
  • Создание набора тайлов, карты тайлов и слоя, используемых нашей картой.

Код для менеджера подземелий находится внутри файла dungeon.js. Давайте пройдемся по нему. Мы используем модули ES6, если их использование и структура вам не понятны, ознакомьтесь с документацией о них в MDN Web Docs.

Начнем с импорта данных уровня:

import level from "./level.js"

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

sprites: {
    floor: 0,
    wall: 826,
}

Мы будем использовать эти значения позже в функции сопоставления так же, как в примере chapter-3/example-1-simple-tilemap/ (здесь).

Функция Initialize() используется для обработки всего кода, который был раньше был внутри функции create(). Эта функция получает в качестве аргумента текущую вызывающую сцену.

initialize: function (scene) {
    this.scene = scene //текущая сцена
    scene.level = level.map(r => r.map(t => t == 1 ? this.sprites.wall : this.sprites.floor))

    const tileSize = 16
    const config = {
        data: scene.level,
        tileWidth: tileSize,
        tileHeight: tileSize,
    }
    const map = scene.make.tilemap(config)
    const tileset = map.addTilesetImage('tiles', 'tiles', tileSize, tileSize, 0, 1) 
    this.map = map.createLayer(0, tileset, 0, 0)
}

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

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

Создание менеджера хода

Есть много способов запрограммировать менеджер хода. Разработчики игр могут изобретать их столько, сколько захотят, и частью очарования рогалика на самом деле могут быть изящные сложные способы разворачивания механики хода.

Однако большинство пошаговых компьютерных игр имели одинаковую механику управления ходом: у каждого персонажа было определенное количество очков, которые он мог использовать в свой ход, выполнение действий стоило очков, а ход заканчивался, когда у вас заканчивались очки. Phaser будет вызывать функцию update много раз в секунду, поэтому мы не можем просто заблокировать в ней действия и императивно обработать ввод игрока. Нам нужно будет написать собственный диспетчер очереди поверх частых вызовов при обновлении. Код будет напоминать конечный автомат —каждая игровая сущность будет менять свое состояние между: наличием баллов, которые нужно потратить, отсутствием баллов и обновлением своих баллов. Простой способ реализации диспетчера хода — это просто управлять движением игрока, как в экшн-игре, а после каждого хода перебирает другие игровые объекты и их действия. Наш пример будет делать что-то более сложное, чем это, фактически не переходя к сверхсложному решению. Наша цель будет заключаться в реализации механики, аналогичной описанной ранее.

Все наши игровые сущности, будь то игрок, монстры или что-то еще, что мы изобретем в будущем, будут новыми классами JS . Эти классы обязательно будут реализовывать следующие методы:

Метод Пояснение
turn() Вызывается, когда наступает их очередь хода. Должен выполнить все действия, необходимые для этого хода.
over() Возвращает логичесий флаг кончились ли очки хода для этого объекта или нет.
refresh() Вызывается перед новым ходом.

В начале хода наш менеджер вызывает функцию refresh() для каждой сущности. Затем каждая сущность выполнит свой метод turn(). Если over() возвращает истину для всех сущностей, начинается новый ход. Причина наличия метода over() заключается в том, что если вы не вернете в нем true, то эта сущность получит еще один вызов turn(). Это позволяет сущности выполнять множество действий за ход в будущем, например, создавать монстра, который перемещается на несколько плиток за ход, в то время как игрок перемещает только одну. Это довольно легко может вселить в игрока страх.

Диспетчер хода находится в собственном модуле в файле turnManager.js. Это синглтон (шаблон проектирования одиночка) и используется в методе update() в файле game.js. Код находится внутри объекта tm (сокращение от Turn manager - менеджер хода). Для хранения сущностей в подземелье мы будем использовать тип данных JavaScript Set. Наш диспетчер будет содержить методы для добавления и удаления объектов из этого множества. Кроме того менеджер хода имитирует предыдущий рабочий процесс и содержит свои функции turn(), over() и refresh(), которые вызывают соответствующие методы для каждой сущности, присутствующей в множестве.

Давайте рассмотрим код, используемый для управления сущностями:

entities: new Set(),
addEntity: (entity) => tm.entities.add(entity),
removeEntity: (entity) => tm.entities.remove(entity),

Использование множества Set для хранения сущностей не позволит нам добавить одну и ту же сущность дважды. Подобные ошибки иногда трудно отследить, поэтому использование структуры данных, не поддерживающей добавление одного и того же объекта более одного раза, делает наш код более безопасным. Здесь есть две функции: одна для добавления объекта, а другая для его удаления; мы не используем функцию removeEntity() в этом примере, но мы будем использовать ее в будущем.

Затем давайте реализуем код для функции turn(), которая отвечает за вызов метода turn() каждой сущности. Как было написано ранее, мы могли бы выбрать более простой менеджер хода, но это было бы так весело, как этот. Что делает функция turn()? Она перебирает множество сущностей, проверяя, закончились ли очки хода каждой сущности — метод over(). Если это не так, она вызывает метод turn() данной сущности, а затем прерывает (break) цикл.

Это прерывание важно, потому что оно позволяет снова вызвать turn() для этой сущности перед вызовом turn() других сущностей, поскольку цикл будет запускаться снова с самого начала после прерывания во время следующего вызова метода update() сцены, что позволяет нам создавать объекты, которые имеют несколько действий за ход, что пригодится, когда мы начнем создавать новые классы персонажей и монстров.

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

Чтобы справиться с этим, в диспетчере хода есть простой код, обходящий данную проблему. Он отслеживает время последнего запуска функции turn() в миллисекундах и разрешает ее повторный вызов только в том случае, если с момента последнего вызова прошло 150 миллисекунд. Это все равно что сделать передышку в быстрой машине, чтобы можно было двигаться немного медленнее и наслаждаться видом. Мы сохраняем свойство в объекте tm с именем lastCall и инициализируем его текущей датой. Кроме того, есть свойство interval, которое содержит количество миллисекунд, которое мы хотим ждать между ходами.

turn: () => {
    let now = Date.now()
    let limit = tm.lastCall + tm.interval
    if (now > limit) {
        for (let e of tm.entities) {
            if (!e.over()) {
                e.turn()
                break;
            }
        }
        tm.lastCall = Date.now()
    }
},

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

Класс игрока

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

Класс PlayerCharacter экспортируется по умолчанию в player.js и в нем импортируется диспетчер подземелий, который является синглтоном (одиночкой), поэтому у него есть доступ к сцене и данным уровня для расчета его движения.

В этой игровой сущности мы используем концепцию точек (очков) движения, которая обычна в варгеймах и пошаговых RPG-играх. По сути, у игрового объекта есть определенное количество очков движения, которые можно использовать за ход. Каждый раз, когда они двигаются, они тратят очко движения. После того, как точки движения закончаться в его методе turn(), его метод over() вернет true. Наш игровой персонаж будет начинать с одной точки движения и при каждом вызове refresh() будет возвращать эту точку. В будущем, когда мы добавим больше сложности в игру, у нас появятся и другие моменты, но пока это все, что нам нужно, поскольку этот пример касается только движения.

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

constructor(x, y) {
    this.movementPoints = 1
    this.cursors = dungeon.scene.input.keyboard.createCursorKeys()
    this.x = x
    this.y = y
    this.sprite = 29

    dungeon.map.putTileAt(this.sprite, this.x, this.y)
}

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

Теперь, когда мы понимаем механику можем легко реализовать функции refresh() иover()`.

refresh() {
    this.movementPoints = 1
}
over() {
    return this.movementPoints == 0
}

Довольно просто, не правда ли? Функция turn() немного сложнее и напоминает код, использованный в главе 2 для перемещения текста. В начале этой функции мы сохраняем текущую позицию игрока и создаем логическое значение для хранения флага перемещается игрок или нет.

let oldX = this.x
let oldY = this.y
let moved = false

Затем надо проверить остались ли у игрока очки движения, проверить каждую клавишу курсора и при необходимости обновить координаты.

if (this.movementPoints > 0) {
    if (this.cursors.left.isDown) {
        this.x -= 1
        moved = true
    }
    if (this.cursors.right.isDown) {
        this.x += 1
        moved = true
    }
    if (this.cursors.up.isDown) {
        this.y -= 1
        moved = true
    }
    if (this.cursors.down.isDown) {
        this.y += 1
        moved = true
    }
    if (moved) {
        this.movementPoints -= 1
    }
}

Если moved равно true, то надо вычесть точку из movementPoints, что в конечном итоге приведет к тому, что over() вернет true и завершит ход игрока. К концу этой части кода координаты персонажа игрока будут в новой позиции, но экран еще не обновлен, поэтому мы можем отменить движение, если игрок на самом деле движется через стену.

let tileAtDestination = dungeon.map.getTileAt(this.x, this.y)
if (tileAtDestination.index == dungeon.sprites.wall) {
    this.x = oldX
    this.y = oldY
}

Функция getTileAt() — это функция, обратная функции putTileAt(), которую мы видели раньше. Наконец, нужно просто нарисовать игрока в новой позиции и вернуть тайл пола в старой позиции.

if (this.x !== oldX || this.y !== oldY) {
    dungeon.map.putTileAt(this.sprite, this.x, this.y)
    dungeon.map.putTileAt(dungeon.sprites.floor, oldX, oldY)
}

Класс игрока завершен. Он не делает ничего, кроме управления движением, но таков наш текущий проект. Пришло время интегрировать все это обратно в сцену.

Обновление сцены

Файл game.js для этого примера гораздо проще, чем предыдущие, так как мы извлекли большую часть логики в отдельные модули. Он очень похож на предыдущий пример, но наверху мы начинаем с импорта наших новых модулей и класса игрока.

import dungeon from "./dungeon.js"
import tm from "./turnManager.js"
import PlayerCharacter from "./player.js"

По сравнению с предыдущим примером изменения коснулись только функций create() и update(). Функция preload() остается тем же — только загружает spritesheet.

Посмотрите насколько оптимизирована новая функция create():

create: function () {
    dungeon.initialize(this)
    let player = new PlayerCharacter(15, 15)
    tm.addEntity(player)
},

Она просто инициализирует диспетчер подземелий, передавая ему саму сцену, а затем создает новый экземпляр игрока и добавляет его в диспетчер хода.

Функция update() также довольно проста. Она проверяет, закончились ли ходы (over()), если да, то все сущности обновляются (refresh()), а затем снова и снова вызывается turn().

update: function () {
    if (tm.over()) {
        tm.refresh()
    }
    tm.turn()
}

Когда вы загрузите этот пример в браузер, вы увидите подземелье с игроком в комнате в верхнем левом углу, как на рис. 3-5. Вы можете использовать клавиши со стрелками для перемещения персонажа. Удерживая нажатой клавишу, персонаж можно медленно перемещать в соответствующем направлении. Вы не сможете проходить стены, но зато можете перемещаться по диагонали, нажимая две клавиши со стрелками одновременно, т.к. как код функции turn() проверяет все клавиши за одну итерацию. Рисунок 3-5. Игровое подземелье.

Упражнение

Можете ли вы изменить класс игрока, чтобы у него было больше ходов за ход? Сможете ли вы заставить игрока прорваться сквозь стены?

Резюме

Эта глава, наконец, привела нас на путь разработки roguelike. Вы много работали, и теперь у вас есть и подземелье, и подвижный персонаж. Подведем итоги тому, что мы узнали:

  • Как использовать функции жизненного цикла сцены Phaser такие как preload(), create() и update() при разработки roguelike.
  • Как реализовать пошаговую механику поверх библиотеки разработки игр, не зависящей от жанра.
  • Что такое тайловые карты и как их использовать.

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