Создание игры с парами из 120 строк кода

Автор: Уэйн Эллис (Twitter @codemwnci)

Phaser JS - это игровая платформа HTML5 для создания настольных браузеров или мобильных игр с использованием HTML 5 и Javascript.

Я немного поигрался с Phaser. Я дошел до того момента, когда смог сделать несколько игр, особенно с помощью нескольких учебных пособий и примеров, доступных в Интернете, но я хотел понять это глубже. Я написал книгу и много блогов в прошлом, поэтому по опыту знаю, что один из действительно лучших способов получить глубокие знания - это сделать шаг назад и попытаться объяснить это кому-то другому. Если вы можете честно и точно описать это кем-то другим, чтобы это имело смысл (без необходимости блефовать!), То, скорее всего, вы понимаете материал.

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

Цель

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

Установка

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

PairsGame\
+ index.html
+ game.js
+ assets\

Пока мы не будем ничего помещать в каталог ресурсов, сделаем это позже.

Далее нам нужен способ запуска нашего приложения. Мы не можем просто открыть файл index.html в веб-браузере, потому что браузеры обычно предотвращают запуск javascript из файловой системы (по соображениям безопасности). Итак, нам нужно обслуживать это через веб-сервер. Есть много способов сделать это, и если у вас есть опыт веб-программирования, у вас может быть свой предпочтительный подход, но для простоты я предпочитаю Python или Node.

Если у вас уже установлен Python, вы можете просто запустить

python -m SimpleHTTPServer 9000

или, если вы предпочитаете Node, то с помощью диспетчера пакетов Node вы можете сделать

npm install -g http-server
http-server -p9000

очевидно, что для примера Node вам нужно установить пакет http-server только один раз.

Если у вас нет ни того, ни другого, то, поскольку мы изучаем здесь JavaScript, я бы предложил пример Node, и вы можете загрузить Node, который включает NPM, с https://nodejs.org/.

Оба примера работают на порту 9000, поэтому укажите в браузере адрес http: // localhost: 9000 /, и вы должны быть готовы к работе! Хорошо, вы на самом деле ничего не видите, потому что мы ничего не помещали в наш index.html, но именно здесь мы переходим к интересным вещам!

Запуск сборки

Мы начнем с index.html, так как как только он будет создан, мы можем оставить его в покое. Откройте его в своем любимом текстовом редакторе и введите следующий код.

<!doctype html>
<html>
<head>
  <title>Pairs</title>
</head>
<body>
  <div id=”pairs-game” class=”game”></div>
  <script src=”//cdnjs.cloudflare.com/ajax/libs/phaser/2.4.2/phaser.min.js"></script>
  <script src=”game.js”></script>
</body>
</html>

Этот код просто создает тег DIV, в котором Phaser настраивает игру, затем загружает PhaserJS и, наконец, загружает наш игровой файл game.js. Остальное в значительной степени представляет собой шаблонный HTML-код.

Теперь, когда у нас есть HTML-код для загрузки нашей игры, нам нужно начать работу над кодом JavaScript, который будет создавать игру и логику событий. Здесь все становится немного сложнее, поэтому сначала мы должны взглянуть на самую суть Phaser, а именно на цикл обработки событий.

Цикл событий

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

  1. preload: как следует из названия, эта функция предварительно загружает игровые ресурсы перед запуском игры. Обычно это были активы, загруженные в игровой мир, готовые к созданию.
  2. create: функция create используется для создания игрового мира, например спрайтов, тайловых карт и групп спрайтов. Метод create вызывается, когда этап игры загружается (а для нашей игры у нас есть только один этап), после завершения функции предварительной загрузки.
  3. update: функция обновления вызывается на протяжении всей игры через определенные промежутки времени. Двигатель стремится делать это со скоростью 60 раз в секунду, но здесь нет никаких гарантий. Этот метод часто используется для захвата игровых событий, таких как нажатия клавиш, нажатия кнопок, движение мыши и т. Д., А затем для обновления переменных состояния игры в результате этих входных данных.
  4. render: после функции обновления игровой движок визуализирует игровой мир и спрайты. Большинство объектов в Phaser визуализируются автоматически, но метод визуализации вызывается после визуализации этих объектов для выполнения окончательной постобработки.

Код игры

Мы начнем с создания скелета нашего игрового кода.

(function() {
  ‘use strict’;
  function Game() {}
  // variables will go here later
  Game.prototype = {
    preload: function() {
    },
    create: function () {
    },
    update: function () {
    },
    render: function() {
  
    }
  };
  var game = new Phaser.Game(800, 520, Phaser.AUTO, ‘pairs-game’);
  game.state.add(‘game’, Game);
  game.state.start(‘game’);
}());

Этот скелет делает для нас несколько вещей. Сначала мы вызываем строгий режим для наилучшего использования JavaScript. В сети есть множество документации о том, почему мы должны использовать строгий режим, поэтому я не буду повторять ее здесь. Следующие несколько строк создают объект Game и добавляют к этому объекту 4 функции, описанные в игровом цикле. Это эффективно создает объект состояния игры.

Последние несколько строк кода мы должны изучить немного глубже.

var game = new Phaser.Game(800, 520, Phaser.AUTO, ‘pairs-game’);

Эта строка создает игровой объект фазера, который, в свою очередь, настраивает каркас и движок игрового мира. Первый параметр (800) указывает ширину игрового холста, второй параметр (520) указывает высоту игрового холста, третий параметр указывает режим рендеринга (CANVAS или WEBGL), который мы установили на AUTO, чтобы разрешить Phaser, чтобы выбрать лучший для нас метод. Последний параметр указывает идентификатор тега DIV, который мы создали в HTML-файле в самом начале. Его можно оставить пустым, и Phaser просто добавит игровой элемент в конец тега BODY. Однако мы создали тег DIV, который дает нам больший контроль над размещением игры на странице HTML, поэтому нам нужно указать это значение.

game.state.add(‘game’, Game);
game.state.start(‘game’);

Первая строка последних фрагментов кода добавляет созданный нами объект State в список состояний, доступных в игровом мире. Создание более одного состояния выходит за рамки данного руководства, но игра может иметь много состояний (например, Menu, GameLoop, GameOver и т. Д.). Таким образом добавляются дополнительные состояния, что позволяет аккуратно разделить игровую логику.

Вторая строка кода сообщает игровому движку, что нужно запустить состояние игры. Так как у нас есть только один, мы просто говорим ему, чтобы он запустил состояние «игра», то же самое, что мы только что добавили в игровой мир. Это немедленно запустит игровой цикл с вызовом preload → create → update → render для созданного нами объекта Game. Но сейчас мы должны просто увидеть черный экран 800 x 520 пикселей. Итак, следующий шаг - приступить к построению игровой логики.

Предварительная загрузка

Как уже упоминалось, предварительная загрузка добавляет активы для игры в игровой движок, готовые к использованию. Наша игра будет содержать 20 карточек, поэтому нам понадобится 11 изображений (неоткрытая карточка плюс 10 пар). Вы можете создавать свои собственные изображения или скачивать изображения со страницы GitHub. Сохраните их в каталоге ресурсов как back.png, 0.png, 1.png… .etc. Затем нам нужно загрузить эти активы. Обновите функцию предварительной загрузки, чтобы она выглядела следующим образом:

preload: function() {
  this.load.image(‘back’, ‘assets/back.png’);
  this.load.image(‘0’, ‘assets/0.png’);
  this.load.image(‘1’, ‘assets/1.png’);
  this.load.image(‘2’, ‘assets/2.png’);
  this.load.image(‘3’, ‘assets/3.png’);
  this.load.image(‘4’, ‘assets/4.png’);
  this.load.image(‘5’, ‘assets/5.png’);
  this.load.image(‘6’, ‘assets/6.png’);
  this.load.image(‘7’, ‘assets/7.png’);
  this.load.image(‘8’, ‘assets/8.png’);
  this.load.image(‘9’, ‘assets/9.png’);
},

Поскольку мы создали объект State, это дает нам доступ ко всем функциям и свойствам объекта State. Свойство load - это объект Loader, который, в свою очередь, дает нам доступ к функции изображения, загружающей изображение в игровой мир, доступной по предоставленному уникальному имени.

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

Создавать

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

  1. Нам нужно создать 20 спрайтов, составляя 10 пар
  2. Нам нужно создать еще 20 спрайтов, показывающих не перевернутые карты.
  3. Нам нужно перетасовать колоду карт, чтобы случайным образом расположить пары.
  4. Нам нужно скрыть 10 пар и показать только неоткрытые карты.

Эти 4 шага достигаются заменой функции create следующим кодом:

create: function () {
  for (var i = 0; i < 10; i++) {   
    images.push(this.game.add.sprite(0,0,’’+i));
    images.push(this.game.add.sprite(0,0,’’+i));
  }
  
  this.shuffle(images);
  for (var i = 0; i < 4; i++) {
    for (var j = 0; j < 5; j++) {
      var idx = i*5+j;
      cards[idx] = this.game.add.sprite(j*TILE_SIZE,i*TILE_SIZE,’back’);
      cards[idx].index = idx;
      images[idx].x = j*TILE_SIZE;
      images[idx].y = i*TILE_SIZE;
      images[idx].visible = false;
      cards[idx].inputEnabled = true;
      cards[idx].events.onInputDown.add(this.doClick);
      cards[idx].events.onInputOver.add(function(sprite) { sprite.alpha = 0.5; });
      cards[idx].events.onInputOut.add(function(sprite) { sprite.alpha = 1.0; });
    }
  }
},
shuffle: function(o) {
  for(var j, x, i = o.length; i; j = Math.floor(Math.random() * i), x = o[--i], o[i] = o[j], o[j] = x);
  return o;
},

Мы проигнорируем функцию перемешивания, это выходит за рамки данного руководства о том, как это работает, но достаточно сказать, что Google + StackOverflow ответил на запрос о том, как перемешать массив в JavaScript, и эта сложная, но аккуратная маленькая функция выполняет свою работу. Обычно я бы использовал такую ​​библиотеку, как underscore.js, но я не хотел добавлять дополнительные зависимости для этого руководства.

Пошаговое выполнение кода в первом цикле for функции create выполняет шаг 1 наших требований, создавая 10 пар изображений. Все они по умолчанию находятся в верхнем левом углу игровой области, а имя загруженного спрайта - это число от 0 до 10 (которое связано с одним из изображений от 0 до 10, загруженных в функцию предварительной загрузки). Код добавления спрайта имеет 3 параметра (xCo-ordinates, yCo-ordinates, imageKey). Координаты x и y начинаются с 0,0 в верхнем левом углу игровой области, переходя к 800, 520 (так как это размер, который мы настроили) в правом нижнем углу игровой области.

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

Следующий цикл for - это двойной цикл. Внешний цикл i считает 0 → 3, представляя каждую строку, а внутренний цикл j считает 0 → 4, представляя каждый элемент в строке. Таким образом, код внутри выполняется 20 раз (по 5 элементов в 4 строках). Первая строка вычисляет индекс, который переводит двумерный список в одномерный массив. idx = i * 5 + j, в результате получится набор индексов, которые выглядят следующим образом

[ 0][ 1][ 2][ 3][ 4]
[ 5][ 6][ 7][ 8][ 9]
[10][11][12][13][14]
[15][16][17][18][19]

Когда у нас есть указатель, мы создаем новый спрайт, который будет отображать неоткрытую карточку. Мы делаем это так же, как и при создании первого спрайта, за исключением того, что на этот раз вместо того, чтобы размещать координаты (0,0), мы разместим его в соответствии с положением, которое оно должно быть на экране. Поскольку переменная цикла for i представляет строки, мы просто умножаем размер плитки на этот индекс строки (первая строка - 0, вторая строка - 130 и т. Д.) И устанавливаем ее в yCoordinates. Мы делаем то же самое для xCoordinates, но с использованием переменной j. Мы также создаем свойство спрайта, называемое index, и сохраняем текущее значение индекса. Это будет использоваться позже, когда мы будем переключаться между перевернутыми и неоткрытыми картами.

Затем мы обновляем координаты x и y перетасованных фрагментов изображения, чтобы они находились в правильном (не 0,0) месте.

На данный момент у нас есть по две плитки в каждой из 20 локаций игровой зоны; плитка изображения и неотвернутая плитка. Наконец, нам нужно скрыть плитки изображения, установив для свойства visible значение false. Мы будем переключаться между неотвернутой плиткой и плиткой изображения, видимой в результате щелчков мышью.

Наконец, в двойной цикл for мы добавляем события мыши. Первая строка inputEnabled сообщает Phaser, что мы хотим отслеживать события мыши. Функция onInputDown отслеживает щелчки мыши и вызывает функцию doClick. Функция onInputOver отслеживает наведение мыши на спрайт. Он выполняет встроенную функцию, которая изменяет прозрачность (альфа) на 50%, чтобы показать, какая плитка в данный момент находится в фокусе. Функция onInputOut вызывается, когда указатель мыши удаляется от спрайта. В нашем коде выполняется встроенная функция, чтобы установить прозрачность обратно на ноль, то есть альфа 100%.

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

var TILE_SIZE = 130;
var cards = [];
var images = [];

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

doClick: function (sprite) {
},

На этом этапе мы должны увидеть экран с перевернутыми картами в конфигурации 5 x 4 и небольшое пространство сбоку для отображения текущего счета. Все это выглядит очень красиво, но не приносит ничего интересного, кроме эффекта наведения курсора мыши! Итак, теперь нам нужно добавить в игровую логику.

doClick

Функция doClick будет вызываться, когда игрок щелкает неотвернутую плитку (поскольку мы устанавливаем событие onInputDown только для спрайтов карт, а не для спрайтов изображений).

Шаги, которые мы предпримем, будут

  1. Если это первая из двух плиток, которые мы пытаемся объединить, сохраните индекс плитки, по которой щелкнули мышью, в переменной firstClick.
  2. Если это вторая из двух плиток, сохраните индекс плитки, по которой щелкнули мышью, в переменной «secondClick».
  3. Независимо от первого или второго щелчка, затем сохраните время, когда мы щелкнули (это будет использоваться в функции обновления для ожидания 500 миллисекунд перед отменой неудачных совпадений), скройте неоткрытую карточку и покажите перевернутую карточку (используя visible свойство карты и спрайтов изображения).
  4. Также при втором щелчке проверьте ключ спрайта изображения (пронумерованный 0 → 10) для обоих нажатых индексов, чтобы увидеть, есть ли совпадения.
  5. Если у нас есть совпадение, добавьте несколько очков к счету и очистите переменные first / secondClick, чтобы мы могли начать щелкать больше плиток.
  6. Если совпадений нет, удалите несколько очков из счета и установите для логической переменной noMatch значение true (что будет проверяться функцией обновления.

Следующий код выполняет 6 шагов. Это делается в основном в порядке 6 шагов, за исключением шага 3, но это описано в комментариях ниже.

doClick: function (sprite) {  
  if (firstClick == null) {
    firstClick = sprite.index;  // step 1
  }
  else if (secondClick == null) {
    secondClick = sprite.index; // step 2
    // step 4
    if (images[firstClick].key === images[secondClick].key) {      
      // step 5
      score += 50;
      firstClick = null; secondClick = null;
    }
    else {
      // step 6
      score -= 5;
      noMatch = true;
    }
  }
  else {
    return; // don’t allow a third click, instead wait for the update loop to flip back after 0.5 seconds
  }
  // step 3 --we only get here on first or second click due to
  // the else statement returning 
  clickTime = sprite.game.time.totalElapsedSeconds();
  sprite.visible = false;
  images[sprite.index].visible = true;
},

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

var firstClick, secondClick;
var noMatch, clickTime;
var score = 100;

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

Обновлять

Как мы уже упоминали, метод обновления вызывается до 60 раз в секунду для запуска игрового цикла. Это идеальное место для проверки любых временных действий в вашей игровой логике. В нашем случае нам нужно сделать пару вещей:

  1. Нам нужно проверить, не найдено ли совпадений
  2. Нам нужно проверить время между noMatch и текущее время, достаточное для того, чтобы игрок мог зарегистрировать изображения неудачных совпадений.
  3. По истечении времени задержки мы скрываем карточки изображений, показываем неоткрытые карточки и очищаем переменные first / secondClick, чтобы функция doClick могла снова начать принимать щелчки мыши. Мы также сбрасываем переменную noMatch, чтобы функция обновления не выполняла ненужную обработку.

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

update: function () {
  if (noMatch) {
    if (this.game.time.totalElapsedSeconds() — clickTime > 0.5) {       
       cards[firstClick].visible = true;
       cards[secondClick].visible = true;
       images[firstClick].visible = false;
       images[secondClick].visible = false;
       firstClick = null; secondClick = null;
       noMatch = false;
    }
  }
},

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

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

Оказывать

Вернемся к механике игрового цикла. Функция рендеринга вызывается после функции обновления и после того, как все спрайты будут автоматически отрисованы в игре. Мы уже отслеживаем счет, добавляя к нашим начальным 50 очкам за каждый матч и удаляя 5 очков за каждый неудачный матч, поэтому все, что нам нужно сделать, это отобразить его в оставшемся месте с правой стороны.

Нам нужно заменить пустую функцию рендеринга следующими строками кода:

render: function() {
  this.game.debug.text(‘Score: ‘ + score, 660, 20);
},

Это просто записывает текст Score: 100 или другой текущий счет в координатах x: 660, y: 20. Поскольку функция рендеринга вызывается до 60 раз в секунду, это гарантирует, что оценка всегда будет актуальной.

Вывод

Надеюсь, это демонстрирует, насколько легко начать работу с фреймворком Phaser. Это руководство лишь поверхностно описывает возможности Phaser. Целью этого урока было показать

  1. Состояния игры
  2. Игровой цикл
  3. Рендеринг спрайтов
  4. События мыши

В Phaser есть гораздо больше, включая анимацию, анимацию, тайловые карты, создание игровых карт из JSON / CSV, физику, звуки, интеграцию клавиатуры / геймпада, несколько состояний игры, масштабирование для устройств разного размера и т. Д. Я надеюсь, что это руководство вызвало у вас интерес . Не стесняйтесь оставлять комментарии / отзывы, и это может мотивировать меня написать больше руководств или даже книгу по этому классному фреймворку.

Исходный код и изображения для игры можно найти на GitHub.