Разработка игр

Создание веб-игры за час

Michael Oneppo

Исходный код можно скачать по ссылке

Продукты и технологии:

Visual Studio 2013 Pro, Visual Studio 2013 Community, ASP.NET

В статье рассматриваются:

  • базовая философия разработки игр;
  • применение веб-технологий для разработки игр;
  • добавление игровых элементов управления и ИИ (искусственного интеллекта).

Разработка игр не требует совершенно нового набора знаний и навыков. По сути, ваши текущие навыки в веб-разработке с применением HTML, JavaScript, CSS и других средств замечательно подходят к широкому спектру игр. Когда вы создаете игру на основе веб-технологий, она будет работать почти на любом устройстве в браузере.

Чтобы доказать это, я продемонстрирую создание игры с нуля, используя веб-технологии и всего две внешние библиотеки, причем сделаю все это менее чем за час. Я буду рассказывать о самой разнообразной тематике, связанной с разработкой игр, — от базового дизайна и разметки, элементов управления и спрайтов до искусственного интеллекта (ИИ) (artificial intelligence, AI), пригодного для простого оппонента. Я даже собираюсь создать игру такой, чтобы она работала на ПК, планшетах и смартфонах. Если у вас есть некоторый опыт в программировании в качестве веб-разработчика или в другой области разработки, но нет никакого опыта в написании игр, эта статья послужит вам отправной точкой. Если вы дадите мне один час, обещаю ввести вас в курс дела.

Приступаем

Я веду всю разработку в Visual Studio, которая позволяет быстро запускать веб-приложения по мере внесения изменений. Удостоверьтесь, что у вас наиболее новая версия Visual Studio (скачайте с bit.ly/1xEjEnX), чтобы вы могли следовать за мной. Я использовал Visual Studio 2013 Pro, но обновил код с помощью Visual Studio 2013 Community.

Это приложение не потребует серверного кода, поэтому я начинаю с создания нового пустого проекта веб-страницы в Visual Studio. Я буду использовать пустой шаблон C# для веб-сайта, выбрав вариант с Visual C# после выбора File | New | ASP.NET Empty Web Site.

В HTML-файле index нужно лишь три ресурса: jQuery, основная таблица стилей и основной JavaScript-файл. Я добавляю в проект пустой CSS-файл style.css и пустой JavaScript-файл ping.js, чтобы избежать ошибок при загрузке страницы:

<!DOCTYPE html>
<html>
<head>
  <script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-2.1.1.min.js"></script>
  <script src="ping.js"></script>
  <link rel="stylesheet" href="style.css"></script>
</head>
<body>
</body>
</html>

Базовый дизайн

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

Общий дизайн Ping
Рис. 1. Общий дизайн Ping

Player Игрок
Ball Мяч
Left Controls Управление слева
Scoreboard Табло
Opponent Оппонент
Right Controls Управление справа

Разработав разметку игры, остается лишь добавить каждый элемент в HTML, чтобы создать игру. Однако стоит отметить, что я буду группировать табло со счетом и элементы управления, чтобы они были расположены вместе. На рис. 2 видно, как я по одному добавил нужные элементы.

Рис. 2. Начальная HTML-разметка

<div id="arena">
  <div id="score">
    <h1>
      <span id="playerScore">0</span>
      -
      <span id="opponentScore">0</span>
    </h1>
  </div>
  <div id="player"></div>
  <div id="opponent"></div>
  <div id="ball"></div>
  <div id="controls-left">
    <div id="up"></div>
    <div id="down"></div>
  </div>
  <div id="controls-right">
    <div id="left"></div>
    <div id="right"></div>
  </div>
</div>

Играем стильно

Если бы вы загрузили эту страницу, то ничего не увидели бы, потому что никакого стиля не применено. Я уже подготовил ссылку на файл main.css в своем HTML, поэтому помещу все CSS в новый файл с тем же именем. Первым делом я позиционирую все элементы на экране. Тело страницы должно занимать весь экран, поэтому сначала я уделяю внимание этому:

body {
  margin: 0px;
  height: 100%;
}

Во-вторых, мне нужно, чтобы арена была заполнена фоновым изображением (рис. 3):

#arena {
  background-image: url(arena.png);
  background-size: 100% 100%;
  margin: 0px;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

Фоновое изображение для арены
Рис. 3. Фоновое изображение для арены

Далее я размещаю табло. Я хочу, чтобы оно находилось вверху по центру — поверх других элементов. Команда «position: absolute» позволяет разместить его где угодно и оставить там: 50% — позиционирует табло посередине верхней части окна, но начиная с самой левой части элемента табло (scoreboard element). Чтобы гарантировать точную центровку, я использую свойство transform, а свойство z-index обеспечивает, что табло будет всегда находиться поверх других элементов:

#score {
  position: absolute;
  z-index: 1000;
  left: 50%;
  top: 5%;
  transform: translate(-50%, 0%);
}

Я также хочу использовать текст со шрифтом в стиле ретро. Большинство браузеров позволяет включать собственные шрифты. Я счел подходящим шрифт Press Start 2P от codeman38 (zone38.net). Чтобы добавить шрифт к табло, я должен создать новое начертание шрифта (font face):

@font-face {
  font-family: 'PressStart2P';
  src: url('PressStart2P.woff');
}

Теперь результаты (scores) находятся в теге h1, поэтому я могу задать этот шрифт для всех тегов h1. На случай отсутствия этого шрифта я предусмотрю несколько запасных вариантов:

h1 {
  font-family: 'PressStart2P', 'Georgia', serif;
}

Для других элементов я буду использовать таблицу изображений-спрайтов. Таблица спрайтов (sprite sheet) содержит все необходимые игре изображения в одном файле (рис. 4).

Таблица спрайтов для Ping
Рис. 4. Таблица спрайтов для Ping

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

Любому элементу, имеющему изображение в этой таблице, будет назначен класс sprite. Тогда для каждого элемента я буду использовать background-position, чтобы определить, какую часть таблицы спрайтов мне нужно показывать:

.sprite {
  background-image: url("sprites.png");
  width: 128px;
  height: 128px;
}

Затем я добавлю класс sprite ко всем элементам, которые будут использовать таблицу спрайтов. Для этого мне придется ненадолго переключиться на HTML:

<div id="player" class="sprite"></div>
<div id="opponent" class="sprite"></div>
<div id="ball" class="sprite"></div>
<div id="controls-left">
  <div id="up" class="sprite"></div>
  <div id="down" class="sprite"></div>
</div>
<div id="controls-right">
  <div id="left" class="sprite"></div>
  <div id="right" class="sprite"></div>
</div>

Теперь мне нужно указать позиции каждого спрайта в таблице для каждого элемента. И вновь я буду делать это с помощью background-position, как показано на рис. 5.

Рис. 5. Добавление смещений для таблицы спрайтов

#player {
  position: absolute;
  background-position: 0px 128px;
}
#opponent {
  position: absolute;
  background-position: 0px 0px;
}
#ball {
  position: absolute;
  background-position: 128px 128px;
}
#right {
  background-position: 64px 192px;
}
#left {
  background-position: 64px 0px;
}
#down {
  background-position: 128px 192px;
}
#up {
  background-position: 128px 0px;
}

Свойство position: absolute в player, opponent и ball позволит мне перемещать их по полю, используя JavaScript. Если вы посмотрите на страницу теперь, то увидите, что к элементам управления и мячу прикреплены ненужные куски. Дело в том, что размеры спрайтов меньше 128 пикселей по умолчанию, поэтому я подстраиваю их под правильный размер. Мяч всего один, и я задаю его размер напрямую:

#ball {
  position: absolute;
  width: 64px;
  height: 64px;
  background-position: 128px 128px;
}

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

.control {
  margin: 16px;
  width: 64px;
  height: 64px;
}

После добавления этого класса элементы управления в игре выглядят гораздо лучше:

<div id="controls-left">
  <div id="up" class="sprite control"></div>
  <div id="down" class="sprite control"></div>
</div>
<div id="controls-right">
  <div id="left" class="sprite control"></div>
  <div id="right" class="sprite control"></div>
</div>

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

#controls-left {
  position: absolute;
  left: 0; bottom: 0;
}
#controls-right {
  position: absolute;
  right: 0; bottom: 0;
}

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

Скачущий мяч

Теперь я заставлю мяч перемещаться по полю. В случае JavaScript-кода я ссылался в HTML на файл ping.js точно так же, как делал это с CSS. Я добавлю этот код в новый файл с тем же именем. Я создам объекты для мяча и каждого из игроков, но буду использовать для объектов шаблон фабрики.

Это простая концепция. Функция Ball создает новый мяч, когда вы вызываете ее. Использовать ключевое слово new не требуется. Этот шаблон исключает некоторую путаницу вокруг переменной Ball, проясняя доступные свойства объекта. А поскольку у меня всего час на создание этой игры, нужно свести к минимуму любые запутанные концепции.

Структура этого шаблона, когда я создаю простой класс Ball, показана на рис. 6.

Рис. 6. Класс Ball

var Ball = function( {
  // Список переменных, видимых только объекту
  // (закрытые переменные)
  var velocity = [0,0];
  var position = [0,0];
  var element = $('#ball');
  var paused = false;
  // Метод, который перемещает мяч с учетом его скорости.
  // Используется только на внутреннем уровне и не доступен
  // вне объекта.
  function move(t) {
  }
  // Обновляет состояние мяча и пока просто проверяет,
  // не приостановлена ли игра, и перемещает мяч, если она
  // не на паузе. Эта функция будет предоставлена
  // как метод объекта.
  function update(t) {
    // Сначала обрабатывается движение мяча
    if(!paused) {
      move(t);
    }
  }
  // Ставит движение мяча на паузу
  function pause() {
    paused = true;
  }
  // Начинает движение мяча
  function start() {
    paused = false;
  }
  // Теперь явно задаем, что могут использовать потребители
  // объекта Ball. Прямо сейчас есть возможность обновлять
  // состояние мяча, а также запускать и останавливать
  // его движение.
  return {
    update:       update,
    pause:        pause,
    start:        start
}

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

var ball = Ball();

Теперь я хочу заставить мяч перемещаться и скакать по экрану. Сначала мне нужно через какой-то интервал вызывать функцию update, чтобы создать анимацию мяча. Современные браузеры предоставляют функцию, предназначенную для этой цели, — requestAnimationFrame. Она принимает функцию как аргумент и вызовет эту переданную функцию, когда она в следующий раз запустит свой цикл анимации. Это позволяет плавно перемещать мяч, когда браузер готов к обновлению. Вызывая переданную функцию, он дает ей время, измеряемое в секундах, так как страница должна загрузиться. Это крайне важно, чтобы анимация не потеряла целостность со временем. В игре requestAnimationFrame используется так:

var lastUpdate = 0;
var ball = Ball();
function update(time) {
  var t = time - lastUpdate;
  lastUpdate = time;
  ball.update(t);
  requestAnimationFrame(update);
}
requestAnimationFrame(update);

Заметьте, что requestAnimationFrame снова вызывается в функции, когда обновление мяча закончено. Это гарантирует непрерывность анимации.

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

var ball;
var lastUpdate;
$(document).ready(function() {
  lastUpdate = 0;
  ball = Ball();
  requestAnimationFrame(update);
});

Поскольку я знаю скорость мяча (velocity) и время с момента ее последнего обновления, я могу выполнить некоторые простые физические расчеты, чтобы перемещать мяч вперед:

var position = [300, 300];
var velocity = [-1, -1];
var move = function(t) {
  position[0] += velocity[0] * t;
  position[1] += velocity[1] * t;  
  element.css('left', position[0] + 'px');
  element.css('top', position[1] + 'px');
}

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

Рис. 7. Простая физика отскакивания мяча

var move = function(t) {
  // Если мяч ударяется в верхнюю или нижнюю границу,
  // он получает обратную вертикальную скорость
  if (position[1] <= 0 || position[1] >= innerHeight) {
    velocity[1] = -velocity[1];
  }
  // Если мяч ударяется в левую или правую границу,
  // он получает обратную горизонтальную скорость
  if (position[0] <= 0 || position[0] >= innerWidth) {
    velocity[0] = -velocity[0];
  }
  position[0] += velocity[0] * t;
  position[1] += velocity[1] * t;
  element.css('left', (position[0] - 32) + 'px');
  element.css('top', (position[1] - 32) + 'px');
}

Перемещаемый игрок

Теперь пришла пора создать объекты Player. Первый шаг в реализации этого класса — заставить функцию move изменять позицию игрока. Переменная side будет указывать, на какой стороне поля находится игрок, что будет определять горизонтальную позицию игрока. Значение y, переданное функции move, сообщает, насколько следует переместить игрока вверх или вниз:

var Player = function (elementName, side) {
  var position = [0,0];
  var element = $('#'+elementName);
  var move = function(y) {
  }
  return {
    move: move,
    getSide:      function()  { return side; },
    getPosition:  function()  { return position; }
  }
}

На рис. 8 определяются перемещения игрока, причем его движение прекращается, если его спрайт достигает верхней или нижней границы окна.

Теперь я могу создать двух игроков и перемещать их по соответствующим сторонам экрана:

player = Player('player', 'left');
player.move(0);
opponent = Player('opponent', 'right');
opponent.move(0);

Рис. 8. Элементы управления перемещением спрайта игрока

var move = function(y) {
  // Подстраиваем позицию игрока
  position[1] += y;
  // Если игрок выходит за край экрана, двигаем его обратно
  if (position[1] <= 0)  {
    position[1] = 0;
  }
  // Высота игрока - 128 пикселей, поэтому останавливаем его
  // до того, как любая его часть выйдет за границу экрана
  if (position[1] >= innerHeight - 128) {
    position[1] = innerHeight - 128;
  }
  // Если игрок движется к правой границе, задаем его позицию
  // по правому краю экрана
  if (side == 'right') {
    position[0] = innerWidth - 128;
  }
  // Наконец, обновляем позицию игрока на странице
  element.css('left', position[0] + 'px');
  element.css('top', position[1] + 'px');
}

Ввод с клавиатуры

Итак, вы можете передвигать игрока, но он не будет перемещаться без команд. Добавьте некоторые элементы управления к игроку слева. Вам нужны два способа управления этим игроком: с клавиатуры (на ПК) и сенсорным вводом (на планшетах и смартфонах).

Чтобы обеспечить согласованность между сенсорным вводом и вводом от мыши на различных платформах, я буду использовать отличную инфраструктуру унификации — Hand.js (handjs.codeplex.com). Прежде всего я добавлю скрипт в HTML в раздел head:

<script src="hand.minified-1.3.8.js"></script>

Рис. 9 демонстрирует применение Hand.js и jQuery для управления игроком при нажатии клавиш на клавиатуре A и Z или при касании сенсорных элементов управления.

Рис. 9. Добавление элементов управления с поддержкой сенсорного ввода и ввода с клавиатуры

var distance = 24;  // смещение на каждом шаге игрока
$(document).ready(function() {
  lastUpdate = 0;
  player = Player('player', 'left');
  player.move(0);
  opponent = Player('opponent', 'right');
  opponent.move(0);
  ball = Ball();
  // pointerdown – универсальное событие для всех типов
  // средств указания: пальца, мыши, стилуса и т. д.
  $('#up')    .bind("pointerdown", function()
    {player.move(-distance);});
  $('#down')  .bind("pointerdown", function()
    {player.move(distance);});
  requestAnimationFrame(update);
});
$(document).keydown(function(event) {
  var event = event || window.event;
  // Этот код преобразует keyCode (число) из события в букву
  // верхнего регистра, чтобы выражение switch
  // было более читаемым
  switch(String.fromCharCode(event.keyCode).toUpperCase()) {
    case 'A':
      player.move(-distance);
      break;
    case 'Z':
      player.move(distance);
      break;
  }
  return false;
});

Ловим мяч

Я хочу, чтобы игроки ловили скачущий мяч. Когда он пойман, у него появляется владелец, и он следует движениям владельца. На рис. 10 в метод move мяча добавлена функциональность, позволяющая мячу перемещаться вслед за владельцем.

Рис. 10. Заставляем мяч следовать за своим владельцем

var move = function(t) {
  // Если владелец есть, перемещаем мяч
  // в соответствии с позицией владельца
  if (owner !== undefined) {
    var ownerPosition = owner.getPosition();
    position[1] = ownerPosition[1] + 64;
    if (owner.getSide() == 'left') {
      position[0] = ownerPosition[0] + 64;
    } else {
      position[0] = ownerPosition[0];
    }
  // Иначе перемещаем мяч согласно законам физики. Заметьте,
  // что горизонтальное отскакивание удалено; мяч должен
  // пролетать мимо игрока, если он не пойман.
  } else {
    // Если мяч ударяется в верхний или нижний край поля,
    // обращаем вертикальную скорость
    if (position[1] - 32 <= 0 || position[1] +
      32 >= innerHeight) {
      velocity[1] = -velocity[1];
    }
    position[0] += velocity[0] * t;
    position[1] += velocity[1] * t;
  }
  element.css('left', (position[0] - 32) + 'px');
  element.css('top',  (position[1] - 32) + 'px');
}

На данный момент способа получить позицию объекта Player пока нет, поэтому я добавлю аксессоры getPosition и getSide в объект Player:

return {
  move: move,
  getSide:      function()  { return side; },
  getPosition:  function()  { return position; }
}

Теперь, если у мяча есть владелец, он будет следовать за этим владельцем. Но как определить владельца? Кто-то должен поймать мяч. На рис. 11 показано, как узнать, когда один из спрайтов игроков касается мяча. Когда это происходит, я устанавливаю владельцем мяча этого игрока.

Первый шаг в реализации класса player — заставить функцию move изменять позицию игрока.

Рис. 11. Обнаружение коллизии для мяча и игроков

var update = function(t) {
// Сначала обрабатывается перемещение мяча
if(!paused) {
    move(t);
}
// Мяч под управлением игрока, обновление не требуется
if (owner !== undefined) {
    return;
}
// Сначала проверяем, будет ли захвачен мяч игроком
var playerPosition = player.getPosition();
  if (position[0] <= 128 &&
      position[1] >= playerPosition[1] &&
      position[1] <= playerPosition[1] + 128) {
    console.log("Grabbed by player!");
    owner = player;
}
// Аналогично для оппонента...
var opponentPosition = opponent.getPosition();
  if (position[0] >= innerWidth - 128 &&
      position[1] >= opponentPosition[1] &&
      position[1] <= opponentPosition[1] + 128) {
    console.log("Grabbed by opponent!");
    owner = opponent;
}

Если вы сейчас попытаетесь поиграть в эту игру, то обнаружите, что мяч отскакивает от верхнего края экрана и что можно двигать игрока, чтобы поймать мяч. А как бросить мяч? Для этого и предназначены элементы управления по правую руку. На рис. 12 игроку добавлена функция fire, а также свойство aim.

Рис. 12. Свойство aim и функция fire для мяча

var aim = 0;
var fire = function() {
  // Если у мяча нет владельца, ничего не делаем
  if (ball.getOwner() !== this) {
    return;
  }
  var v = [0,0];
  // В зависимости от того, на какой стороне находится игрок,
  // мяч можно кидать в различных направлениях. Мяч должен
  // перемещаться с той же скоростью независимо от направления.
  // Математические операции помогут определить, что смещение
  // на .707 пикселей в направлениях x и y дает ту же
  // скорость, что и смещение одного пикселя
  // только в одном направлении.
  if (side == 'left') {
    switch(aim) {
    case -1:
      v = [.707, -.707];
      break;
    case 0:
      v = [1,0];
      break;
    case 1:
      v = [.707, .707];
    }
  } else {
    switch(aim) {
    case -1:
      v = [-.707, -.707];
      break;
    case 0:
      v = [-1,0];
      break;
    case 1:
      v = [-.707, .707];
    }
  }
  ball.setVelocity(v);
  // Освобождаем управление мячом
  ball.setOwner(undefined);
}
// Остальной код определения Ball помещается сюда...
return {
  move: move,
  fire: fire,
  getSide:      function()  { return side; },
  setAim:       function(a) { aim = a; },
  getPosition:  function()  { return position; },
}

Код на рис. 13 дополняет функцию, связанную с клавиатурой. Он задает aim и fire объекта игрока. Прицеливание работает слегка иначе. Когда клавиша прицеливания освобождается, задается прямое направление (straightforward).

Рис. 13. Подготавливаем функцию прицеливания для игрока

$(document).keydown(function(event) {
  var event = event || window.event;
  switch(String.fromCharCode(event.keyCode).toUpperCase()) {
    case 'A':
      player.move(-distance);
      break;
    case 'Z':
      player.move(distance);
      break;
    case 'K':
      player.setAim(-1);
      break;
    case 'M':
      player.setAim(1);
      break;
    case ' ':
      player.fire();
      break;
  }
  return false;
});
$(document).keyup(function(event) {
  var event = event || window.event;
  switch(String.fromCharCode(event.keyCode).toUpperCase()) {
    case 'K':
    case 'M':
      player.setAim(0);
      break;
  }
  return false;
});

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

$('#left')  .bind("pointerdown", function() {player.setAim(-1);});
$('#right') .bind("pointerdown", function() {player.setAim(1);});
$('#left')  .bind("pointerup",   function() {player.setAim(0);});
$('#right') .bind("pointerup",   function() {player.setAim(0);});
$('body')   .bind("pointerdown", function() {player.fire();});

Ведем счет

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

function checkScored() {
  if (position[0] <= 0) {
    pause();
    $(document).trigger('ping:opponentScored');
  }
  if (position[0] >= innerWidth) {
    pause();
    $(document).trigger('ping:playerScored');
  }
}

На рис. 14 показан код, который реагирует на эти события, обновляя счет и передавая мяч в руки игрока. Добавьте этот код вниз JavaScript-документа.

Рис. 14. Обновление табло

$(document).on('ping:playerScored', function(e) {
  console.log('player scored!');
  score[0]++;
  $('#playerScore').text(score[0]);
  ball.setOwner(opponent);
  ball.start();
});
$(document).on('ping:opponentScored', function(e) {
  console.log('opponent scored!');
  score[1]++;
  $('#opponentScore').text(score[1]);
  ball.setOwner(player);
  ball.start();
});

Теперь, когда мяч перемещается мимо вашего оппонента (что не трудно, так как оппонент не движется), ваш счет будет расти, а мяч передаваться оппоненту. Однако оппонент будет просто держать мяч.

Делаем игру немного умнее

Игра почти готова. Жаль, что играть вам не с кем. В качестве последнего этапа я покажу, как управлять оппонентом с помощью простой ИИ. Оппонент будет пытаться держаться параллельно мячу, когда тот летит в его сторону. Если оппонент ловит мяч, он перемещается случайным образом и бросает мяч в случайном направлении. Чтобы сделать ИИ чуточку человечнее, я добавлю задержки во все, что делается на поле. Это не особо умный ИИ, но все же против него будет можно играть.

При разработке системы такого рода хорошо мыслить состояниями. ИИ оппонента имеет три возможных состояния: following (следует), aiming/shooting (прицеливание/бросок) и waiting (ожидание). Начнем только с объекта AI:

function AI(playerToControl) {
  var ctl = playerToControl;
  var State = {
    WAITING: 0,
    FOLLOWING: 1,
    AIMING: 2
  }
  var currentState = State.FOLLOWING;
}

В зависимости от состояния AI (ИИ) я хочу, чтобы он выполнял разные действия. По аналогии с мячом я создам функцию update, которую смогу вызывать в requestAnimationFrame, чтобы AI действовал в соответствии со своим состоянием:

function update() {
  switch (currentState) {
    case State.FOLLOWING:
      // Делаем что-то, чтобы следовать за мячом
      break;
    case State.WAITING:
      // Делаем что-то, чтобы ждать
      break;
    case State.AIMING:
      // Делаем что-то, чтобы прицелиться
      break;
  }
}

Состояние FOLLOWING достаточно простое. Оппонент перемещается в вертикальном направлении мяча, и AI переходит в состояние WAITING, чтобы добавить некоторое замедление реакции. Эти два состояния показаны на рис. 15.

При разработке системы такого рода хорошо мыслить состояниями.

Рис. 15. Простое состояние FOLLOWING

function moveTowardsBall() {
  // Перемещаем на то же расстояние, на которое
  // переместился бы и игрок, чтобы все было по-честному
  if(ball.getPosition()[1] >= ctl.getPosition()[1] + 64) {
    ctl.move(distance);
  } else {
    ctl.move(-distance);
  }
}
function update() {
  switch (currentState) {
    case State.FOLLOWING:
      moveTowardsBall();
      currentState = State.WAITING;
    case State.WAITING:
      setTimeout(function() {
        currentState = State.FOLLOWING;
      }, 400);
      break;
    }
  }
}

Используя код с рис. 15, AI выбирает между следованием за мячом и ожиданием доли секунды. Теперь добавляем код в функцию update уровня всей игры:

function update(time) {
  var t = time - lastUpdate;
  lastUpdate = time;
  ball.update(t);
  ai.update();
  requestAnimationFrame(update);
}

Запустив игру, вы увидите, что оппонент следует за перемещениями мяча — не такой уж и плохой ИИ, состоящий менее чем из 30 строк кода. Конечно, если оппонент поймает мяч, он ничего делать не будет. Поэтому на излете отведенного мне часа пора заняться обработкой действий для состояния AIMING. Я хочу, чтобы ИИ заставлял вашего оппонента случайным образом перемещаться несколько раз, а затем кидать мяч в случайном направлении. На рис. 16 добавлена закрытая функция, которая именно это и делает. Добавление функции aimAndFire к выражению case с AIMING создает полнофункциональный ИИ, против которого интересно играть.

Рис. 16. ИИ прицеливается и бросает мяч

function repeat(cb, cbFinal, interval, count) {
  var timeout = function() {
    repeat(cb, cbFinal, interval, count-1);
  }
  if (count <= 0) {
    cbFinal();
  } else {
    cb();
    setTimeout(function() {
      repeat(cb, cbFinal, interval, count-1);
    }, interval);
  }
}
function aimAndFire() {
  // Повторяет перемещение от 5 до 10 раз
  var numRepeats = Math.floor(5 + Math.random() * 5);
  function randomMove() {
    if (Math.random() > .5) {
      ctl.move(-distance);
    } else {
      ctl.move(distance);
    }
  }
  function randomAimAndFire() {
    var d = Math.floor( Math.random() * 3 - 1 );
    opponent.setAim(d);
    opponent.fire();
    // Наконец, переходим в состояние FOLLOWING
    currentState = State.FOLLOWING;
  }
  repeat(randomMove, randomAimAndFire, 250, numRepeats);
}

Заключение

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


Michael Oneppo - креативный технолог и бывший менеджер программ в группе Microsoft Direct3D. В последнее время работает в качестве главного технического директора в технологической некоммерческой компании Library For All и ведет исследования по программе NYU Interactive Telecommunications Program для получения степени магистра.​

Выражаю благодарность за рецензирование статьи эксперту Магомету Амину Ибрагиму (Mohamed Ameen Ibrahim).