Играть в видеоигры весело. JavaScript — это весело (для некоторых). Итак…….. Почему не оба? В этом эпизоде ​​мы создадим базовый игровой движок в качестве основы для дальнейшей работы.

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

Существует множество существующих библиотек и основ для разработки игр на JavaScript, которые вы можете использовать (Phaser, Unity, Kiwi, PlayCanvas), мой личный фаворит — Unity. Однако в создании чего-то с нуля есть определенный шарм и удовлетворение. Если вы хотите сразу же приступить к созданию сумасшедшего 3D-кроссплатформенного бегемота, я бы, конечно, посоветовал проверить некоторые из фреймворков/движков, упомянутых выше, но ради отличного образовательного упражнения мы создадим простой маленькая игра сверху вниз с нуля.

Прежде чем мы продолжим, я хотел бы упомянуть, что во время написания и разработки этой простой игры я буду слушать живое выступление Legend of Zelda: Symphony of the Goddesses, чтобы действительно проникнуться духом проект. Я настоятельно рекомендую вам запустить его и сделать то же самое, это потрясающе!

Общий обзор

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

Мы будем использовать HTML5 Canvas API для обработки рендеринга нашей игры, и мы будем разрабатывать наш JavaScript с помощью Gulp, Browserify и Browser Sync. Что касается управления тем, что происходит в игре, мы будем прикреплять объекты состояния ко всем нашим различным компонентам, чтобы мы могли управлять текущим состоянием каждого компонента по отдельности, что лично я считаю самым простым методом мониторинга и управления данными. .

Я завершил базовый шаблон проекта, который вы можете скачать и использовать, доступный на Github. Кроме того, вы можете посетить репозиторий для демонстрации и просто проверить коммит 3a4f588. Если вы хотите более подробно ознакомиться с настройкой Gulp для проектов, вы можете ознакомиться с предыдущим постом, в котором довольно подробно рассматривается создание файла gulp для Jekyll.

Начиная

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

index.html // Loads up game module for game-playing party time.
/js/       // Main JS root.
- game.js  // Primary game module.
- core/    // Contains core "engine"-related modules.
- players/ // Contains all enemy/character-related modules.
- utils/   // Contains global constants/utility functions.
- world/   // Contains the main world classes and all levels.

Это довольно простая структура, и она должна быть довольно предсказуемой. Для нашего основного файла game.js потребуются соответствующие модули от players/, utils /, core/ и world/ в качестве зависимостей, которые будут очередь требует каких-либо дополнительных зависимостей, необходимых для создания нашей игры. В этой части мы, скорее всего, коснемся только одного (может быть, двух) файлов в каждом каталоге.

Создание области просмотра с холстом

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

Давайте начнем с первого шага, создав базовый игровой модуль внутри /js/game.js.

// /js/game.js
var $container = document.getElementById('container');

// Create base game class
function Game() {
    this.viewport = document.createElement('canvas');
    this.context = viewport.getContext('2d');

    this.viewport.width = 800;
    this.viewport.height = 600;

    // Append the canvas node to our container
    $container.insertBefore(this.viewport, $container.firstChild);

    // Toss some text into our canvas
    this.context.font = '32px Arial';
    this.context.fillText('It\'s dangerous to travel this route alone.', 5, 50, 800);

    return this;
}

// Instantiate the game in a global
window.game = new Game();

// Export the game as a module
module.exports = game;

Здесь мы не делаем ничего особенного, просто создаем холст (6–10), помещаем его в наш контейнер (13), затем визуализируем текст (16–17). Более чем вероятно, что если вы посмотрите на приведенный выше код на мониторе с высокой плотностью пикселей, он выведет текст, но он будет супер размытым и грубым. Чтобы решить эту проблему, мы создадим утилиту, которая рассчитает правильное соотношение пикселей, а затем уменьшит масштаб холста для оптимального качества рендеринга. Мы собираемся основывать наши утилиты на потрясающей статье блога HTML5 Rocks на эту тему и помещать их в нашу удобную директорию utils/ в имя файла utils.canvas.js.

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

// /js/utils/utils.canvas.js
module.exports = {
    methodOne: function () {},
    methodTwo: function () {}
};

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

// /js/utils/utils.canvas.js

/** Determine the proper pixel ratio for the canvas */
getPixelRatio : function getPixelRatio(context) {
  console.log('Determining pixel ratio.');

  // I'd rather not have a giant var declaration block,
  // so I'm storing the props in an array to dynamically
  // get the backing ratio.
  var backingStores = [
    'webkitBackingStorePixelRatio',
    'mozBackingStorePixelRatio',
    'msBackingStorePixelRatio',
    'oBackingStorePixelRatio',
    'backingStorePixelRatio'
  ];

  var deviceRatio = window.devicePixelRatio;

  // Iterate through our backing store props and determine the proper backing ratio.
  var backingRatio = backingStores.reduce(function(prev, curr) {
    return (context.hasOwnProperty(curr) ? context[curr] : 1);
  });

  // Return the proper pixel ratio by dividing the device ratio by the backing ratio
  return deviceRatio / backingRatio;
},

Комментарии довольно многословны, но второстепенное краткое изложение того, что происходит:

  1. Мы создаем массив реквизитов резервного хранилища с правильным префиксом браузера. (10–16)
  2. Мы уменьшаем наш массив до одного коэффициента резервного хранилища, по умолчанию равного 1, если его не существует. (21–23)
  3. Мы делим соотношение пикселей устройства (из окна) на коэффициент поддержки, что дает нам наше соотношение пикселей. (26)

Затем нам нужно применить рассчитанное соотношение к нашему холсту, а затем уменьшить масштаб всего холста с помощью CSS и преобразований, что даст нам предсказуемый высококачественный рендеринг с предсказуемыми размерами. Мы поместим все это в другую утилиту canvas, а затем вернем canvas нашему основному игровому объекту для создания экземпляра и внедрения dom.

// /js/utils/utils.canvas.js

/** Generate a canvas with the proper width / height
 * Based on: http://www.html5rocks.com/en/tutorials/canvas/hidpi/
 */
generateCanvas : function generateCanvas(w, h) {
  console.log('Generating canvas.');

  var canvas = document.createElement('canvas'),
      context = canvas.getContext('2d');
  // Pass our canvas' context to our getPixelRatio method
  var ratio = this.getPixelRatio(context);

  // Set the canvas' width then downscale via CSS
  canvas.width = Math.round(w * ratio);
  canvas.height = Math.round(h * ratio);
  canvas.style.width = w +'px';
  canvas.style.height = h +'px';
  // Scale the context so we get accurate pixel density
  context.setTransform(ratio, 0, 0, ratio, 0, 0);

  return canvas;
}

Опять же, довольно прямолинейно. Вы заметите, что после создания нашего холста и получения контекста мы передаем этот контекст в наш метод getPixelRatio(), а затем умножаем ширину и высоту нашего холста на возвращаемый число, а затем установка ширины и высоты styles на исходную ширину и высоту; это уменьшение холста. Это похоже на то, как если бы вы смотрели видео 1080p на YouTube с шириной 400 пикселей. Наше преобразование затем относительно масштабирует холст на величину, на которую мы его уменьшили, таким образом мы получаем точные размеры в пикселях. Попробуйте убрать setTransform и посмотреть эффекты, очень интересно.

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

var cUtils = require('./utils/utils.canvas.js'),
    $container = document.getElementById('container');

function Game(w, h) {
  // Generate a canvas and store it as our viewport
  this.viewport = cUtils.generateCanvas(w, h);
  this.viewport.id = "gameViewport"; // give the canvas an ID for easy CSS/JS targeting

  // Get and store the canvas context as a global
  this.context = this.viewport.getContext('2d');

  // Append our viewport into a container in the dom
  $container.insertBefore(this.viewport, $container.firstChild);

  // Spit out some text
  this.context.font = '32px Arial';
  this.context.fillStyle = '#fff';
  this.context.fillText('It\'s dangerous to travel this route alone.', 5, 50);

  return this;
}

// Instantiate a new game in the global scope at 800px by 600px
window.game = new Game(800, 600);

module.exports = game;

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

Создание игрового цикла

«Эй, чуваки, не могли бы вы обновить и поставить лайк, перерисовать себя?» — Наша петля, наверное

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

Когда дело доходит до анимации и управления холстом, нужно пройти еще несколько обручей, чем при анимации типичного узла dom, в основном потому, что вы не можете ссылаться на созданное содержимое холста после его рендеринга. Чтобы решить эту проблему, мы собираемся хранить все в центральном состоянии; с отдельными объектами (игроками, врагами и т. д.), сохраняющими свое позиционирование и другие соответствующие данные в своих собственных состояниях. Затем, когда придет время повторного рендеринга, мы вызовем для каждого объекта метод render(), который будет использовать данные в их состояниях для правильного рендеринга объекта.

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

  1. setTimeout или setInterval устанавливают интервал, который будет срабатывать со скоростью, равной нашему целевому FPS.
  2. Используйте window.requestAnimationFrame, который является рекомендуемым методом, чтобы сообщить браузеру, что мы хотим сделать анимацию, и активировать наш метод рендеринга перед следующей перерисовкой.

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

Леса из петли

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

/js/core/game.loop.js   // The actual loop
/js/core/game.update.js // An update method
/js/core/game.render.js // A rendering method

Этим модулям потребуется доступ к некоторым константам, которые мы будем устанавливать в нашем глобальном игровом модуле, поэтому мы собираемся создать новое свойство constants вместе с пустым состояние в нашем основном игровом модуле.

function Game(w, h, targetFps, showFps) {
    // Setup some constants
    this.constants = {
        width: w,
        height: h,
        targetFps: targetFps,
        showFps: showFps
    };

    // Instantiate an empty state object
    this.state = {};
    . . .
}

// Instantiate a new game in the global scope at 800px by 600px
window.game = new Game(800, 600, 60, true);

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

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

Модуль обновления игры

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

// /js/core/game.update.js

/** Game Update Module
 * Called by the game loop, this module will
 * perform any state calculations / updates
 * to properly render the next frame.
 */
function gameUpdate ( scope ) {
    return function update( tFrame ) {
        var state = scope.state || {};

        // If there are entities, iterate through them and call their `update` methods
        if (state.hasOwnProperty('entities')) {
            var entities = state.entities;
            // Loop through entities
            for (var entity in entities) {
                // Fire off each active entities `render` method
                entities[entity].update();
            }
        }

        return state;
    }
}

module.exports = gameUpdate;

Как я уже сказал, ничего супер сумасшедшего не происходит. Мы внедряем глобальную область видимости (8), затем создаем новый объект на основе глобального состояния (10), перебираем сущности в глобальном состоянии (13), запускаем update() для каждого активного объекта (16–22), а затем возвращает его (15). Легкий.

Одна интересная вещь, которую вы, возможно, заметили, заключается в том, что я ожидаю, что сначала будет передан параметр scope, а затем я возвращаю другую функцию, которая на самом деле выполняет все Работа. Вы увидите это и в методе рендеринга, и это скорее личное предпочтение, чем что-либо еще. В качестве альтернативы можно было бы удалить возвращаемую функцию, переместить ее содержимое в основную функцию gameUpdate и вместо того, чтобы внедрять область действия, мы использовали глобальную window.game объект. Поскольку я хочу избежать жесткого кодирования этих ссылок, я просто добавляю область действия в качестве параметра.

Модуль рендеринга игры.

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

// /js/core/game.render.js

/** Game Render Module
 * Called by the game loop, this module will
 * perform use the global state to re-render
 * the canvas using new data. Additionally,
 * it will call all game entities `render`
 * methods.
 */
function gameRender( scope ) {
    // Setup globals
    var w = scope.constants.width,
        h = scope.constants.height;

    return function render() {
        // Clear out the canvas
        scope.context.clearRect(0, 0, w, h);

        // Spit out some text
        scope.context.font = '32px Arial';
        scope.context.fillStyle = '#fff';
        scope.context.fillText('It\'s dangerous to travel this route alone.', 5, 50);

        // If we want to show the FPS, then render it in the top right corner.
        if (scope.constants.showFps) {
            scope.context.fillStyle = '#ff0';
            scope.context.fillText('FPS', w - 100, 50);
        }

        // If there are entities, iterate through them and call their `render` methods
        if (scope.state.hasOwnProperty('entities')) {
            var entities = scope.state.entities;
            // Loop through entities
            for (var entity in entities) {
                // Fire off each active entities `render` method
                entities[entity].render();
            }
        }
    }
}

module.exports = gameRender;

Как и в случае с модулем обновления, мы внедряем контекст игры (10), затем очищаем холст (17), рендерим наш фиктивный контент (19–22) и настраиваем его для отображения FPS в правом верхнем углу экрана. холст, если установлен флаг showFps (25–28).

Единственная причудливость, возникающая здесь, находится между строками 31 и 38, где мы перебираем все активные сущности с помощью простого цикла for…in (если в глобальном состоянии установлены какие-либо сущности, как в строке 31), а затем запускаем render() метод каждого активного объекта (36).

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

Петля гибели

Теперь пришло время перейти к нашему мифическому циклу, который, безусловно, будет самой сложной частью кода, который мы когда-либо писали. Как я упоминал ранее, мы будем использовать requestAnimationFrame для обработки нашего цикла, как это рекомендовано MDN и основано на информации от Paul Irish, в сочетании с вместе с некоторым регулированием частоты кадров, основанным на некоторых потрясающих ответах на переполнение стека.

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

// /js/core/game.loop.js

/** Game Loop Module
 * This module contains the game loop, which handles
 * updating the game state and re-rendering the canvas
 * (using the updated state) at the configured FPS.
 */
function gameLoop ( scope ) {
    var loop = this;

    // Main game rendering loop
    loop.main = function mainLoop( tframe ) {
        // Request a new Animation Frame
        // setting to `stopLoop` so animation can be stopped via
        // `window.cancelAnimationFrame( loop.stopLoop )`
        loop.stopLoop = window.requestAnimationFrame( loop.main );

        // Update the game state
        scope.state = scope.update( now );
        // Render the next frame
        scope.render();
    };

    // Start off main loop
    loop.main();

    return loop;
}

module.exports = gameLoop;

Сам по себе цикл совсем не страшен, и если вы ознакомились с документацией MDN по анатомии веб-игры, она должна быть вам очень знакома; мы просто создаем обратный вызов и передаем его в window.requestAnimationFrame, а затем запускаем наши методы обновления и рендеринга, экземпляры которых создаются в глобальном контексте (который был введен в). Я понимаю, что это не самый элегантный способ запуска методов обновления и рендеринга, но я считаю, что модули обновления и рендеринга должны создаваться и открываться глобально, если по какой-либо причине их нужно запускать вручную. Альтернативой этому может быть требование модулей в модуле игрового цикла и создание их экземпляров с внедренным контекстом.

Далее мы начнем с регулирования частоты кадров. Это довольно много, поэтому я постараюсь разбить это как можно лучше. Сначала нам нужно настроить наши переменные для расчета частоты кадров. Нам потребуется текущая временная метка анимации, предыдущая временная метка анимации, а также разница между ними; наш целевой FPS и наш целевой интервал между тактами анимации (1000 / fps).

    // /js/core/game.loop.js, lines 9 - 30

    // Initialize timer variables so we can calculate FPS
    var fps = scope.constants.targetFps, // Our target fps
        fpsInterval = 1000 / fps, // the interval between animation ticks, in ms (1000 / 60 = ~16.666667)
        before = window.performance.now(), // The starting times timestamp

        // Set up an object to contain our alternating FPS calculations
        cycles = {
            new: {
                frameCount: 0, // Frames since the start of the cycle
                startTime: before, // The starting timestamp
                sinceStart: 0 // time elapsed since the startTime
            },
            old: {
                frameCount: 0,
                startTime: before,
                sineStart: 0
            }
        },
        // Alternating Frame Rate vars
        resetInterval = 5, // Frame rate cycle reset interval (in seconds)
        resetState = 'new'; // The initial frame rate cycle

    loop.fps = 0; // A prop that will expose the current calculated FPS to other modules

    // Main game rendering loop
    loop.main = function mainLoop( tframe ) { . . . }

Ого, тут много переменных. Помня о комментариях к коду, я хотел бы объяснить одну вещь, а именно объект cycles. Если вы посмотрите на ответ о переполнении стека, о котором я упоминал ранее, там есть много примеров некоторых вычислений для определения и ограничения частоты кадров, но они используют один цикл вычислений, с которым во время тестирования я заметил некоторые проблемы.

  1. Через некоторое время производительность падала из-за постоянного увеличения количества одиночных кадров и переменных прошедшего времени.
  2. Также через какое-то время точность резко падала. Поскольку расчеты зависят от текущего времени и количества кадров по отношению к исходному времени, из-за того, что разрыв был настолько большим, результирующее значение будет неверно отражать фактическую частоту кадров и будет действовать как «усреднение по сравнению с исходным временем». -время-вы-открыли-эту-страницу».

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

Теперь вернемся к нашей петле.

// /js/core/game.loop.js, lines 32. . .82

loop.main = function mainLoop( tframe ) {
    // Request a new Animation Frame
    // setting to `stopLoop` so animation can be stopped via
    // `window.cancelAnimationFrame( loop.stopLoop )`
    loop.stopLoop = window.requestAnimationFrame( loop.main );

    // How long ago since last loop?
    var now = tframe,
        elapsed = now - before,
        activeCycle, targetResetInterval;

    // If it's been at least our desired interval, render
    if (elapsed > fpsInterval) {
        // Set before = now for next frame, also adjust for
        // specified fpsInterval not being a multiple of rAF's interval (16.7ms)
        // ( http://stackoverflow.com/a/19772220 )
        before = now - (elapsed % fpsInterval);

        // Update the game state
        scope.update( now );
        // Render the next frame
        scope.render();
    }
};

Как вы можете видеть выше, дросселирование цикла до нашего идеального fps на самом деле относительно просто. Вот заголовки:

  • Строки 8 и 9: кэшируйте текущую метку времени, затем вычтите из нее предыдущую метку времени, чтобы получить прошедшее время.
  • Строка 13: если время, прошедшее со строки 9, больше, чем наш целевой интервал, продолжите повторную визуализацию.
  • Строки 14–22: установите для переменной, содержащей предыдущую временную метку, текущую временную метку, затем запустите модули обновления и рендеринга.

Довольно прямолинейно, так что теперь давайте рассчитаем текущую частоту кадров и предоставим ее для других модулей (а именно модуля рендеринга).

// /js/core/game.loop.js, lines 49 - 78

before = now - (elapsed % fpsInterval);

// Increment the vals for both the active and the alternate FPS calculations
for (var calc in cycles) {
    ++cycles[calc].frameCount;
    cycles[calc].sinceStart = now - cycles[calc].startTime;
}

// Choose the correct FPS calculation, then update the exposed fps value
activeCycle = cycles[resetState];
loop.fps = Math.round(1000 / (activeCycle.sinceStart / activeCycle.frameCount) * 100) / 100;

// If our frame counts are equal....
targetResetInterval = (cycles.new.frameCount === cycles.old.frameCount
                       ? resetInterval * fps // Wait our interval
                       : (resetInterval * 2) * fps); // Wait double our interval

// If the active calculation goes over our specified interval,
// reset it to 0 and flag our alternate calculation to be active
// for the next series of animations.
if (activeCycle.frameCount > targetResetInterval) {
    cycles[resetState].frameCount = 0;
    cycles[resetState].startTime = now;
    cycles[resetState].sinceStart = 0;

    resetState = (resetState === 'new' ? 'old' : 'new');
}

// Update the game state

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

  • Строки 4–7: Сначала мы собираемся повторить оба цикла, увеличить количество кадров и обновить время, прошедшее с начала цикла.
  • Строки 10 и 11: Установите активный цикл, затем используйте некоторые математические вычисления, чтобы определить и установить для нашего открытого свойства текущую частоту кадров.
  • Строки 12–15: установите целевой интервал сброса. Как только циклы начнут чередоваться, когда активный цикл достигнет своей цели, неактивный цикл будет равен половине цели, поскольку он выполняется параллельно. Поэтому вместо сброса с нашим интервалом мы хотим сбрасывать с удвоенным интервалом.
  • Строки 21–27: если количество кадров в активном цикле больше целевого интервала, сбросить активный цикл в 0 и переключиться на неактивный цикл.

Теперь, когда это завершено, мы можем вернуться к нашему модулю рендеринга (/js/core/game.render.js) и бросить открытый цикл .fps вместо фиктивного текста 'FPS':

scope.context.fillText(scope.loop.fps, w - 100, 50);

Обернув все это вместе

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

. . .
$container.insertBefore(this.viewport, $container.firstChild);

// Instantiate core modules with the current scope
this.update = gameUpdate( this );
this.render = gameRender( this );
this.loop = gameLoop( this );

return this;

Ура! Мы создали, хотя и базовый, собственный рудиментарный игровой движок! Он автоматически генерирует элемент холста с высоким разрешением и вставляет его в наш HTML, обновляет себя с настраиваемой частотой кадров и отображает указанную частоту кадров, используя стратегию чередующегося цикла вычислений (что вы можете увидеть, если бросите журнал частоты кадров обоих циклов в ваш цикл).

Бонус: создание подвижного плеера

Эй, панк, мне обещали интерактивную игру!

Я мог бы закончить на этом, но обычно меня очень раздражает каждый раз, когда я вижу руководство, состоящее из нескольких частей, которое полностью оставляет меня в подвешенном состоянии до того, как произойдет волшебство, поэтому я не хочу быть "тем парнем". Для некоторых людей завершение цикла — это волшебство, и они, вероятно, в данный момент в восторге; Я тоже, но я думаю, что еще более волшебной точкой остановки была бы возможность перемещать маленькую коробку вокруг окна просмотра. Ничего сумасшедшего, достаточно, чтобы быть интерактивным и действительно получать вдохновение, когда вы слышите «Эврика!» момент, когда вы бесцельно перемещаете блок по области просмотра.

Давайте взломать.

Создаем наш модуль плеера

Ранее я постоянно упоминал сущности, которые, по сути, мы и собираемся создать. Если вы раньше играли в Unity, этот термин будет вам знаком, поскольку игры Unity создаются из сущностей. Сущности — это, по сути, интерактивные компоненты, такие как NPC, враги, снаряды и т. д. В конце концов, у нашей сущности будет множество различных методов и свойств, таких как здоровье, урон, атака и другие вещи игрового типа; на данный момент все, что нам нужно, — это состояние, которое будет содержать нашу позицию в области просмотра, метод рендеринга и метод обновления.

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

// /js/players/player.js

/** Player Module
 * Main player entity module.
 */
function Player(scope, x, y) {
    var player = this;

    // Create the initial state
    player.state = {
        position: {
            x: x,
            y: y
        },
        moveSpeed: 1.5
    };

    // Set up any other constants
    var height = 23,
        width = 16;

    // Draw the player on the canvas
    player.render = function playerRender() {
        scope.context.fillStyle = '#40d870';
        scope.context.fillRect(
            player.state.position.x,
            player.state.position.y,
            width, height
        );
    };

    // Fired via the global update method.
    // Mutates state as needed for proper rendering next state
    player.update = function playerUpdate() {
        // Check if keys are pressed, if so, update the players position.
        // Bind the player to the boundary
    };

    return player;
}

module.exports = Player;

Как видите, наша структура сущности очень похожа на все наши другие модули; у нас просто есть объявление экспортированной функции с некоторыми открытыми свойствами и методами. Здесь важно отметить открытые методы render и update, строки 23–30 и 34–37, соответственно. Вы помните, что в наших глобальных модулях update и render мы перебираем все активные сущности и запуск этих методов, поэтому включение их в нашу сущность имеет решающее значение.

Опять же, мы внедряем область действия в модуль, но это не обязательно на 100%; мы также можем внедрить его, отказавшись от параметра scope и используя глобальный window.game для получения контекста. Поскольку мы вводили его ранее, я придерживаюсь этого здесь. Если позже это будет беспокоить меня, я уверен, что изменю это в следующем посте.

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

// /js/game.js, lines 40 - 51

that = this;

var createPlayer = function createPlayer() {
    // Set the state.entities prop to an empty object if it does not exist
    that.state.entities = that.state.entities || {};
    // Instantiate a player as an active entity
    that.state.entities.player = new playerEnt(
        that,
        that.constants.width / 2,
        that.constants.height - 100
    );
}();

Заставляем нашего игрока двигаться

Теперь, когда у нас есть работающая сущность, пришло время заполнить метод обновления сущностей, чтобы он действительно что-то делал. Наша первая задача — заставить нашего игрока двигаться всякий раз, когда мы нажимаем клавиши со стрелками вниз. Поскольку мы не используем jQuery или какие-либо библиотеки, нам нужно найти способ изящно отслеживать события нажатия клавиши в dom, а затем сохранять, нажата ли клавиша в любой момент, как логическое значение. Есть два способа сделать это: мы можем либо вернуть некоторые функции, которые будут действовать как геттеры, либо получить все, что установлено в переменной keys в данный момент (keys.isLeftPressed()), или мы можем использовать Object.defineProperty для фактического создания некоторых геттеров для переменных (keys.isPressed.left). Это, очевидно, полностью оставлено на усмотрение, и я собираюсь использовать Object.defineProperty, потому что мне нравится его синтаксис.

// /js/utils/utils.keysDown.js

/** keysDown Utility Module
 * Monitors and determines whether a key
 * is pressed down at any given moment.
 * Returns getters for each key.
 */
function keysDown() {
    // Set isPressed to an empty object
    this.isPressed = {};
    var left, right, up, down;

    // Set up `onkeydown` event handler.
    document.onkeydown = function (ev) {
        if (ev.keyCode === 39) { right = true; }
        if (ev.keyCode === 37) { left = true; }
        if (ev.keyCode === 38) { up = true; }
        if (ev.keyCode === 40) { down = true; }
    };

    // Set up `onkeyup` event handler.
    document.onkeyup = function (ev) {
        if (ev.keyCode === 39) { right = false; }
        if (ev.keyCode === 37) { left = false; }
        if (ev.keyCode === 38) { up = false; }
        if (ev.keyCode === 40) { down = false; }
    };

    // Define getters for each key
    // * Not strictly necessary. Could just return
    // * an object literal of methods, the syntactic
    // * sugar of `defineProperty` is just so much sweeter :)
    Object.defineProperty(this.isPressed, 'left', {
        get: function() { return left; },
        configurable: true,
        enumerable: true
    });

    Object.defineProperty(this.isPressed, 'right', {
        get: function() { return right; },
        configurable: true,
        enumerable: true
    });

    Object.defineProperty(this.isPressed, 'up', {
        get: function() { return up; },
        configurable: true,
        enumerable: true
    });

    Object.defineProperty(this.isPressed, 'down', {
        get: function() { return down; },
        configurable: true,
        enumerable: true
    });

    return this;
}

module.exports = keysDown();

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

  • Мы подготавливаем свойство isPressed, задавая для него пустой объект, и кэшируем все наши переменные (10, 11).
  • Настройте прослушиватели событий при нажатии клавиши, затем мы определяем, какие клавиши нажаты, и соответствующим образом устанавливаем переменные (14–19).
  • Точно так же мы настраиваем прослушиватели событий при отпускании клавиши и снова соответствующим образом устанавливаем переменные. Это очень важно, так как это скажет нашей игре «Эй, мы больше не движемся влево» (22–27).
  • Наконец, мы определяем несколько геттеров для каждого из наших ключей. Это не самые сложные геттеры в мире, но они потрясающие, поскольку дают нам синтаксис объявления переменной, но возвращают любое значение, активное в данный момент (33–55).

После запроса этого модуля в наш модуль проигрывателя у нас есть доступ к возможности запрашивать, активна ли клавиша или нет в любой момент времени, вызывая это свойство keys в isPressed объект (например, keys.isPressed.left).

// /js/players/player.js
var keys = require('../utils/utils.keysDown.js');

/** Player Module
 * Main player entity module.
 */
function Player(scope, x, y) {
. . .
    player.update = function playerUpdate() {
        // Check if keys are pressed, and if so, update the players position.
        if (keys.isPressed.left) {
            player.state.position.x -= player.state.moveSpeed;
        }

        if (keys.isPressed.right) {
            player.state.position.x += player.state.moveSpeed;
        }

        if (keys.isPressed.up) {
            player.state.position.y -= player.state.moveSpeed;
        }

        if (keys.isPressed.down) {
            player.state.position.y += player.state.moveSpeed;
        }

        // Bind the player to the boundary
    };
. . .
}

module.exports = Player;

Легче, чем ожидалось, да? Кроме того, поскольку мы определили нашу moveSpeed как переменную, позже мы можем динамически изменять скорость движения, чтобы реализовывать такие вещи, как гладкие поверхности, усиление бонусов, спринт и т. д. Единственный недостаток, который у нас остался, это то, что если мы продолжим, например, идти налево, мы можем легко заблудиться в глубинах пустыни вне поля зрения; никогда не вернуться. Давайте это исправим.

Привязка нашей позиции к границе

«Я не хочу выходить за пределы видового экрана, там темно и страшно!» — Наш игрок, наверное

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

// /js/utils/utils.math.js

/**
 * Number.prototype.boundary
 * Binds a number between a minimum and a maximum amount.
 * var x = 12 * 3;
 * var y = x.boundary(3, 23);
 * y === 23
 */

var Boundary = function numberBoundary(min, max) {
    return Math.min( Math.max(this, min), max );
};

// Expose methods
Number.prototype.boundary = Boundary;
module.exports = Boundary;

Все, что мы здесь делаем, это берем минимальную и максимальную границы и применяем нашу текущую позицию, чтобы она не превышала эти две граничные точки. Некоторые «Эврика!» включают в себя то, что, поскольку мы расширяем прототип объекта Number, наш номер доступен нам в методе через this, и я экспортирую его только как модуль, чтобы заставить браузер объединить файл с остальной частью нашего проекта.

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

// /js/players/player.js
var keys = require('../utils/utils.keysDown.js'),
    mathHelpers = require('../utils/utils.math.js');

/** Player Module
 * Main player entity module.
 */
function Player(scope, x, y) {
    . . .

    player.update = function playerUpdate() {
        // Check if keys are pressed, if so, update the players position.
        . . .

        // Bind the player to the boundary
        player.state.position.x = player.state.position.x.boundary(0, (scope.constants.width - width));
        player.state.position.y = player.state.position.y.boundary(0, (scope.constants.height - height));
    };

    . . .
}

module.exports = Player;

Поверьте мне, это НАМНОГО чище, чем добавлять в модуль все эти min max. Как я упоминал ранее, поскольку мы расширили прототип объектов Number с помощью Number.prototype.boundary, этот метод теперь доступно для всех номеров в рамках нашего проекта. Поскольку x и y являются числами, а не строками, они могут использовать границу для возврата правильной позиции.

Подведение итогов

Итак, давайте посмотрим, что мы сделали в этом эпизоде:

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

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

Если вы хотите полностью ознакомиться с кодом для этого сообщения, вы можете проверить его в репозитории github (в частности, тег 0.2.0-p1) или скачать в виде zip или tar. файл.

Безопасных путешествий до следующего раунда!

Первоначально опубликовано на сайте aesinv.com 27 ноября 2015 г.