Цикл игры

Вот оно! Мы, наконец, дошли до той главы, в конце которой на деле получим штуку на экране! Прежде, чем мы начнём волноваться, давайте успокоимся и подумаем минуту о разных вещах.

Что является неотъемлемой частью игры?

С нашим новым пониманием концепции игровых циклов и игр в целом проведём эксперимент и вырежем из игры материал, пока не останется только её суть.

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

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

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

Размышления о цикле нашей игры

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

Наша главная цель — это моделирование и отображение мира. Позвольте прерваться и отметить что нам нужно.

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

Всё позиционирование происходит в функции create(), она запускается один раз, когда создаётся состояние игры. Все движения и столкновения происходят в функции update(), которая вызывается повторно пока активно состояние Game.

Создайте файл с именем game.js внутри папки js и начнём кодить.

Изменение index.html

Мы должны изменить наш файл index.html и включить в него game.js.

index.html: теперь включает наше состояние Game

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Foxnoid Game</title>
    <link rel="stylesheet" href="css/style.css" />
    <script defer src="js/phaser.min.js"></script>
    <script src="js/init.js"></script>
    <script defer src="js/preload.js"></script>
    <script defer src="js/game.js"></script>
  </head>
  <body>
  
    <div id="game"></div>

  </body>
</html>

После включения этого файла в HTML нужно зарегистрировать состояние в init.js как показано ниже.

init.js: с новым игровым состоянием Game

var GameStates = {}; // <-- Объект для хранения всех наших игровых состояний
  
document.addEventListener("DOMContentLoaded", function()  {
  
  // Портретная ориентация игры
  
  var width = 320;
  var height = 480;
  
  var game = new Phaser.Game(width, height, Phaser.CANVAS, "game");
  
  // Добавляем игровое состояние Game
  game.state.add('Preloader', GameStates.Preloader);
  game.state.add('Game', GameStates.Game);
  
  // Запускаем состояние Preloader.
  game.state.start('Preloader');
});

Инициализация нашего мира

Игровые состояния Phaser, как объяснялось в главе об инициализации, имеют различные функции, которые мы можем внедрить в рабочий процесс нашего игрового цикла. Для инициализации мира мы добавим функцию create(), она выполняется один раз при запуске состояния. Мы не собираемся реализовывать эту инициализацию как единый непрерывный блок кода, потому что это утомительно и труднее для понимания. Вместо этого создадим крошечные функции, которые будут вызываться из create().

game.js: где происходит веселье

GameStates.Game = {
  initWorld: function() {
    // Некоторые константы
    this.playerSpeed = 250;
    this.ballSpeed = 220;
    this.blocksPerRow = 5;
    this.blockRows = 3;
    this.playerLives = 13;
  
    // Добавляем фон
    this.add.sprite(0, 0, 'background');
  },
  
  create: function() {
    this.initWorld();
  }
};

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

Отныне, вместо вывода всего файла game.js, я собираюсь размещать только новую функцию, которую вы должны добавить в конец create().

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

Помните, что вы должны вставить запятые между методами. Добавьте следующий метод в объект GameStates.Game.

game.js: функция addPlayer()

addPlayer: function () {
  // Добавляем игрока
  this.player = this.add.sprite(160, 440, 'player');
  this.physics.arcade.enable(this.player);
  this.player.anchor.setTo(0.5, 0);
  this.player.enableBody = true;
  this.player.body.immovable = true;
  this.player.body.collideWorldBounds = true;
  
  // Добавляем вывод жизней игрока
  this.livesDisplay = this.add.text(10, 8, "Lives: " + this.playerLives, {
    fill: "white",
    fontSize: 12
  });
}

Эта функция используется для добавления спрайта игрока в мир. Спрайты — это небольшие штуки, которые движутся и взаимодействуют с миром, всё остальное это фон и отображение информации. Вы можете узнать больше о спрайтах из документации класса Phaser.Sprite.

Мы создаём спрайт используя изображение с именем player, которое было загружено в состоянии Preload. Этот спрайт находится в заданной позиции (160x440) и мы его храним в объекте свойств под именем player. Если вы не уверены в использовании this внутри объекта, то прочтите документацию MDN о this. В основном, поскольку игровое состояние это объект и мы храним player как свойство этого объекта, то player доступен для использования и в других функциях.

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

Phaser поставляется с тремя разными физическими системами, они ранжируются от простой Arcade Physics до полноценного моделирования подобному Box2D. Наша игра не требует сложного моделирования и должна прекрасно работать с Arcade Physics. Вот почему мы используем this.physics.arcade, а не другие системы. Вы можете узнать больше об Arcade Physics из документации Phaser.

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

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

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

Добавление мяча

game.js: функция addBall()

addBall: function () {
  // Добавляем мяч
  this.ball = this.add.sprite(160, 240, 'ball');
  this.physics.arcade.enable(this.ball);
  this.ball.anchor.setTo(0.5, null);
  this.ball.enableBody = true;
  this.ball.body.bounce.setTo(1, 1);
  this.ball.body.velocity.x = this.ballSpeed;
  this.ball.body.velocity.y = this.ballSpeed;
  this.ball.body.collideWorldBounds = true;
}

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

Вы можете видеть, что мы используем константу this.ballSpeed, которая была установлена в initWorld(). Настраивая эту и другие константы вы можете экспериментировать с игрой. Есть некоторые жёстко закодированные значения, которые можно перенести в константы и это сделало бы нашу жизнь намного проще. Может быть в следующей редакции или окончательной версии этого текста я это изменю.

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

game.js: функция addBlocks()

addBlocks: function () {
  // Блоки
  this.blocks = this.game.add.group();
  for (var line = 0; line <= this.blockRows; line++) {
    for (var row = 0; row <= this.blocksPerRow; row++) {
      var posY = (line * 30) + 40;
      var posX = (row * 50) + 40;
      console.log("Adding block at: " + posX + "," + posY)
      var temp = this.add.sprite(posX, posY, 'block');
      this.physics.arcade.enable(temp);
      temp.enableBody = true;
      temp.body.immovable = true;
      this.blocks.add(temp);
    }
  }
}

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

Для начала мы создаём новую группу и храним её свойства под именем blocks. Вы можете узнать больше о группах из документации Phaser Group. Далее используем вложенный цикл чтобы построить ряды и столбцы (так называемая сетка блоков). В этом цикле мы вычисляем положение каждого блока и создаём временный спрайт для его хранения. Затем устанавливаем необходимую физику для этого спрайта и добавляем его в группу.

Функция create()

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

game.js: функция create()

create: function() {
  this.initWorld();
  this.addPlayer();
  this.addBall();
  this.addBlocks();
}

Легко для понимания, не так ли? Сначала мы инициализируем мир путём настройки констант и добавления фона. Затем добавляем игрока, мяч и, наконец, блоки.

После того как это сделано, инициализация готова. Если мы сейчас запустим нашу игру, то увидим неподвижный экран, где всё на своих местах, но при этом ничего не происходит. Наш следующий шаг заключается в реализации функции update() для добавления движения. Мы собираемся сделать это тем же путём, как с функцией create(), которая делает маленькие функции и вызывает их из update().

Моделирование мира

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

Столкновение с блоками

Мы собираемся использовать две функции для проверки столкновений с блоками. Одной из них является сама проверка, которая вызывается из update(). Другой является функция обратного вызова, которая вызывается системой физики, если произошло столкновение. Вначале сделаем проверку.

game.js: функция checkHitWithBlocks()

checkHitWithBlocks: function () {
  this.game.physics.arcade.collide(this.ball, this.blocks, this.ballCollidesWithBlock);
}

Столкновения в Phaser очень легко отслеживать с помощью движка Arcade Physics. Вы просто вызываете this.game.physics.arcade.collide() со спрайтом, который хотите проверить, в качестве первого параметра. Вторым параметром идём спрайт, с которым происходит столкновение, это может быть группа или спрайт. В нашем случае мы проверяем всех членов группы blocks. Третий параметр — функция, которая вызывается при столкновении.

Ниже мы реализуем функцию обратного вызова.

game.js: функция ballCollidesWithBlock()

ballCollidesWithBlock: function(sprite, block) {
  console.log("Collided with block!");
  block.kill();
}

Функция обратного вызова получает два параметра, которыми служат столкнувшиеся спрайты. Первым из них будет наш спрайт игрока, а вторым — блок, по которому игрок ударил. После этого мы делаем kill() для спрайта. Есть две функции, используемые для удаления спрайта из игры, это kill() и destroy(), они имеют разную механику. kill() действительно разрушает и удаляет спрайт из игры. destroy() удаляет из игры, но оставляет в памяти для повторного использования. Это полезно когда вы используете совокупность спрайтов, которые создаются и уничтожаются без потери производительности. Например, это важно для шутеров. В нашем случае нам эта штука не нужна.

Столкновение с игроком

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

game.js: функция ballCollidesWithBlock()

checkHitWithPlayer: function () {
  this.game.physics.arcade.collide(this.ball, this.player);
}

Столкновение с землей

Удар о землю теряет жизнь игрока, а мяч возвращается в исходное положение. Мы не собираемся использовать систему физики для этого, потому что мы установили collideWorldBounds = true и мяч отскакивает автоматически от всех четырёх стен. Мы проверяем, что положение мяча находится ниже ракетки и если это так, то игрок проигрывает.

Вначале создаём resetBall().

game.js: функция resetBall()

resetBall: function() {
  this.ball.reset(160, 240);
  this.ball.body.velocity.x = this.ballSpeed;
  this.ball.body.velocity.y = this.ballSpeed;
}

Теперь реализуем проверку, что игрок проиграл.

game.js: функция ballCollidesWithBlock()

ballCollidesWithGround: function() {
  if (this.ball.y >= 470) {
    this.playerLives -= 1;
    this.resetBall();
  }

  /*
   Обновляем отображение жизней игрока
   */
  this.livesDisplay.setText("Lives: " + this.playerLives);
  
  if (this.playerLives === 0) {
    // Здесь будет состояние завершения игры
  }
  
}

Итак, если мяч находится ниже ракетки игрока, расположенной в 440px, то мы вычитаем жизнь игрока и сбрасываем мяч в исходное положение.

Функция update()

Функция update() очень проста для понимания. В каждом цикле проверяются необходимые столкновения и движения через библиотеку Phaser Arcade.

game.js: функция ballCollidesWithBlock()

update: function() {
  this.checkHitWithBlocks();
  this.checkHitWithPlayer();
  this.ballCollidesWithGround();
}

Резюме

Если мы откроем наш index.html в браузере, то увидим работающую игру и как игрок теряет жизни, поскольку ещё нет управления. Давайте перейдём к следующей главе, чтобы сделать эту игру рабочей!

Автор: Андре Гарсия
Последнее изменение: 23.03.2024