Создаём игру используя canvas и спрайты

в 4:44, , рубрики: canvas, html5, javascript

Веб сейчас везде, и предлагает очень мощную среду для создания и распространения приложений. Вместо цикла: написание кода → компиляция → запуск, просто обновите приложение или даже напиши код «на живую» в браузере. Кроме того, это относительно безболезненно позволяет распространять своё приложение на огромном количестве платформ. Интересно, что в последние несколько лет, разработка игр, используя HTML5, стала реальностью.
Элемент canvas был введен вместе с HTML5 и предоставляет API для работы с ним. API — прост, но если Вы никогда не работали с графикой, Вам потребуется время чтобы привыкнуть. Canvas поддерживается большим количество браузеров, что делает веб — хорошей площадкой для создания игр.
Использовать canvas просто: создаёте тег , создаете контекст отображения в javascript, и используете такие методы как fillRect и drawImage на этот контекст для отображения форм и изображений. API содержит множество методов для создания разнообразных контуров, преобразования изображение и многое другое.
В этой статье, мы создадим игру используя canvas; настоящую игры со спрайтами, слежением за столкновениями, и конечно же взрывами. Что за игра без взрывов!
А вот и игра, которую мы собираемся создать — играть.

Готовимся

Игра может показаться сложной, но на самом деле всё сводится к использованию нескольких компонентов. Я всегда был поражен насколько далеко можно зайти с canvas, несколькими спрайтами, слежением за столкновениями и игровым циклом.
Для того, чтобы полностью сосредоточиться на компонентах игры, я не буду разжевывать каждую строчку кода и API. Это статья написана advanced-уровня, но я надеюсь, что будет понятна для людей всех уровней. В статье предполагается, что Вы уже знакомы с JavaScript и основами HTML. Также немного затронем canvas API и такие основные игровые принципы, как цикл игры.

Создание Canvas

Начнем изучать код. Большая часть игры находится в app.js.
Первое, что мы делаем, это создаём тег и задаём ему ширину и высоту. Мы делаем это динамически, для того чтобы держать все в JS, но вы может создать canvas в HTML документе, и получить его с помощью getElementById. Нет никакой разницы между этими двумя способами, это просто вопрос предпочтений.

// Create the canvas
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
canvas.width = 512;
canvas.height = 480;
document.body.appendChild(canvas);

Canvas имеет метод getContext, который используется для получения контекста отображения. Контекст — это объект, вызывая методы которого, вы взаимодействуете с canvas API. Так же Вы можете передать параметром 'webgl', если вы хотите использовать WebGL для 3D-сцен.
Дальше мы будем использовать переменную ctx для отображения всех элементов.

Цикл игры

Нам нужен такой цикл игры, который бы постоянно обновлял и отображал игру. Вот как это выглядит:

// The main game loop
var lastTime;
function main() {
    var now = Date.now();
    var dt = (now - lastTime) / 1000.0;

    update(dt);
    render();

    lastTime = now;
    requestAnimFrame(main);
};

Мы обновляем и отображаем сцены, и затем используем requestAnimationFrame для постановки в очередь следующего цикла. Действительно проще было бы использовать setTimeout(main, 1000/60), пытаясь отобразить 60 кадров/сек. В сам вверху app.js мы сделали обертку для requestAnimationFrame, так как не все браузеры поддерживают данный метод.
Никогда не используется setTimeout(main, 1000/60), так как он менее точный и тратит много циклов на отображение, тогда, когда это не требуется.
Параметр в dt в функции update — это разница между текущим временем и временем последнего обновления. Никогда не обновляйте сцену используя постоянное значение для кадра (в духе, x += 5). Ваша игра будет будет работать на разных компьютерах/платформах по-разному, по этому необходимо обновлять сцену не зависимо то частоты кадров.
Это достигается путём расчет времени с последнего обновления и выражения всех перемещений в пикселях в секунду. И движение становится следующим x += 50*dt, или 50 пикселей в секунду.

Загрузка ресурсов и запуск игры

Следующая часть кода инициализирует игру и загружает все необходимые ресурсы. Для этого используется один из отдельно написанных вспомогательных классов, resources.js. Это очень простая библиотека, которая загружает все изображения и вызывает событие, когда они все загрузятся.
Игра включается в себя больше количество ресурсов, таких как изображения, данные сцены и другое. Для 2D игр, основным ресурсом являются изображения. Вам необходимо загрузить все ресурсы перед запуском приложения, для того, чтобы было возможно использовать из немедленно.
В JavaScript легко загрузить изображения и использовать их когда они понадобятся:

var img = new Image();
img.onload = function() {
    startGame();
};
img.src = url;

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

(function() {
    var resourceCache = {};
    var loading = [];
    var readyCallbacks = [];

    // Load an image url or an array of image urls
    function load(urlOrArr) {
        if(urlOrArr instanceof Array) {
            urlOrArr.forEach(function(url) {
                _load(url);
            });
        }
        else {
            _load(urlOrArr);
        }
    }

    function _load(url) {
        if(resourceCache[url]) {
            return resourceCache[url];
        }
        else {
            var img = new Image();
            img.onload = function() {
                resourceCache[url] = img;

                if(isReady()) {
                    readyCallbacks.forEach(function(func) { func(); });
                }
            };
            resourceCache[url] = false;
            img.src = url;
        }
    }

    function get(url) {
        return resourceCache[url];
    }

    function isReady() {
        var ready = true;
        for(var k in resourceCache) {
            if(resourceCache.hasOwnProperty(k) &&
               !resourceCache[k]) {
                ready = false;
            }
        }
        return ready;
    }

    function onReady(func) {
        readyCallbacks.push(func);
    }

    window.resources = { 
        load: load,
        get: get,
        onReady: onReady,
        isReady: isReady
    };
})();

Как же это работает: Вы вызываете resources.load со всеми изображениями для загрузки, и затем вызываете resources.onReady для создания callback на событие загрузки всех данных. resources.load не используется позже в игре, только в время старта
Загруженные изображения хранятся в кеше в resourcesCache, и когда все изображения буду загружены, будут вызваны все callback'и. Теперь мы можем просто сделать так:

resources.load([
    'img/sprites.png',
    'img/terrain.png'
]);
resources.onReady(init);

Что бы получить изображение используется resources.get('img/sprites.png). Легко!
Вы можете в ручную загружать все изображения и запускать игру, либо, для упрощения процесса, использовать что-то в духе resources.js.
В приведенном выше коде, init вызывается, когда все изображения будут загружены. Init создаст фоновое изображение, повесит события на кнопки «Играть снова», сброса и старта игры, запустит игру.

function init() {
    terrainPattern = ctx.createPattern(resources.get('img/terrain.png'), 'repeat');

    document.getElementById('play-again').addEventListener('click', function() {
        reset();
    });

    reset();
    lastTime = Date.now();
    main();
}

Состояние игры

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

// Game state
var player = {
    pos: [0, 0],
    sprite: new Sprite('img/sprites.png', [0, 0], [39, 39], 16, [0, 1])
};

var bullets = [];
var enemies = [];
var explosions = [];

var lastFire = Date.now();
var gameTime = 0;
var isGameOver;
var terrainPattern;

// The score
var score = 0;
var scoreEl = document.getElementById('score');

Кажется что много всего, но на самом деле все не так сложно. Большинство из переменных — это отслеживаемые значения: когда игрок последний раз выстрелил (lastFired), как долго запущена игра (gameTime), закончена ли игра (isGameOver), изображение местности (terrainPattern) и очки (score). Так же описаны объекты на карте: пули, враги, взрывы.
Так же есть сущность игрока, в которой отслеживается положение игрока и состояние спрайта. Прежде чем перейдем к коду, давайте поговорим о сущностях и спрайтах.

Сущности и спрайты

Сущности

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

enemies.push({
    pos: [100, 50],
    sprite: new Sprite(/* sprite parameters */)
});

Этот код добавить врага на карту, в положение x = 100, y = 50 с определенным спрайтом.

Спрайты и анимация

Спрайт — это изображение, которое отображает представление сущности. Без анимации спрайты представляют собой обычное изображение, представленное с помощью ctx.drawImage.
Мы можем реализовать анимацию путём загрузки нескольких изображений и смены их в течении времени. Это называется кадр анимации.
image
Если мы будет чередовать эти изображения от первого к последнему, это будет выглядеть так:
image
Для того, чтобы упростить редактирование и загрузку изображений, обычно все компонуют на одном, это называется спрайт картой. Возможно, вы уже знакомы с этой техникой из CSS.
Это спрайт карта для нашей игры (с прозрачным фоном):
image
Мы используем Hard Vacuum набор изображений. Этот набор — это набор bmp файлов, поэтому я скопировал необходимые мне изображения и вставил из на один спрайт лист. Для этого Вам потребуется простой графический редактор.
Будет трудно управлять всеми анимациями вручную. Для этого используем второй вспомогательный класс — sprite.js. Это маленький файл который содержит в себе логику анимации. Посмотрим:

function Sprite(url, pos, size, speed, frames, dir, once) {
    this.pos = pos;
    this.size = size;
    this.speed = typeof speed === 'number' ? speed : 0;
    this.frames = frames;
    this._index = 0;
    this.url = url;
    this.dir = dir || 'horizontal';
    this.once = once;
};

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

  • url: путь к изображению
  • pos: x и y координаты изображения на спрайт карте
  • size: размеры (только одного кадры)
  • speed: скорость анимации в фрейм/с
  • frames: массив индексов фреймов в порядке анимации
  • dir: в каком направлении двигаться по спрайт карте: 'horizontal (по-умолчанию) или 'vertical'
  • once:true, если необходимо отобразить только один цикл анимации, false — по-умолчанию

Аргумент frames, наверное, нуждается дополнительных объяснениях. Подразумевается, что все кадры анимации имеют один размер (это размер передан выше). Во время анимации, система просто «проходит» по спрайт карте по горизонтали или вертикали (в зависимости от значения dir) начиная с позиции pos c с приращением по оси x или y на значение size. Вам необходимо определить frames, для того, чтобы описать как необходимо проходить по кадрам анимации. Значение [0, 1, 2, 3, 2, 1] фреймы будет проходить от начала до конца и обратно к началу.
Только url, pos, size являются обязательными, так как Вам может и не понадобиться анимация.
Каждый Sprite объект имеет метод update, для обновления анимации, и аргументом у него является дельта времени, также как и в нашем глобальном update. Каждый спрайт должен быть обновлён для каждого фрейма.

Sprite.prototype.update = function(dt) {
    this._index += this.speed*dt;
}

Каждый объект Sprite так же имеет метод render для отрисовки себя. В нем находится основная логика анимации. Он следит за тем, какой кадр должен быть отрисован, рассчитывает его координаты на спрайт карте, и вызывает ctx.drawImage для отрисовки кадра.

Sprite.prototype.render = function(ctx) {
    var frame;

    if(this.speed > 0) {
        var max = this.frames.length;
        var idx = Math.floor(this._index);
        frame = this.frames[idx % max];

        if(this.once && idx >= max) {
            this.done = true;
            return;
        }
    }
    else {
        frame = 0;
    }


    var x = this.pos[0];
    var y = this.pos[1];

    if(this.dir == 'vertical') {
        y += frame * this.size[1];
    }
    else {
        x += frame * this.size[0];
    }

    ctx.drawImage(resources.get(this.url),
                  x, y,
                  this.size[0], this.size[1],
                  0, 0,
                  this.size[0], this.size[1]);
}

Мы используем 3-ю форм drawImage, которая позволяет нам указать размер спрайта, смещении и направлении раздельно.

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

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

unction update(dt) {
    gameTime += dt;

    handleInput(dt);
    updateEntities(dt);

    // It gets harder over time by adding enemies using this
    // equation: 1-.993^gameTime
    if(Math.random() < 1 - Math.pow(.993, gameTime)) {
        enemies.push({
            pos: [canvas.width,
                  Math.random() * (canvas.height - 39)],
            sprite: new Sprite('img/sprites.png', [0, 78], [80, 39],
                               6, [0, 1, 2, 3, 2, 1])
        });
    }

    checkCollisions();

    scoreEl.innerHTML = score;
};

Обратите внимание как мы добавляем врагов на карту. Мы добавляем врага если случайное значение меньше установленного порога, и мы добавляем его на правой стороне, за пределами видимости. Ордината устанавливает, путём умножения случайного числа на разность высоты карты и высоты противника. Высота изображения противника «захардкодено», так мы знаем его высоты, и данный код используется как пример.
Порог поднимается каждый раз функцией 1 - Math.pow(.993, gameTime).

Нажатия клавиш

Для обработки нажатия клавиш, я создал ещё одну небольшую библиотеку: input.js. Это очень маленькая библиотека, которая просто сохраняет состояние нажатой клавиши, путем добавления обработчика событий keyup и keydown.
Эта библиотека предоставляет одну единственную функцию — input.isDown. Которая в качестве аргумента принимает символ, например 'a', и возвращает true, в случае, если это клавиша была нажата. Так же Вы можете передать следующие значения:

  • SPACE
  • LEFT
  • RIGHT
  • UP
  • DOWN

Теперь мы можем обрабатывать нажатия клавиш:

function handleInput(dt) {
    if(input.isDown('DOWN') || input.isDown('s')) {
        player.pos[1] += playerSpeed * dt;
    }

    if(input.isDown('UP') || input.isDown('w')) {
        player.pos[1] -= playerSpeed * dt;
    }

    if(input.isDown('LEFT') || input.isDown('a')) {
        player.pos[0] -= playerSpeed * dt;
    }

    if(input.isDown('RIGHT') || input.isDown('d')) {
        player.pos[0] += playerSpeed * dt;
    }

    if(input.isDown('SPACE') &&
       !isGameOver &&
       Date.now() - lastFire > 100) {
        var x = player.pos[0] + player.sprite.size[0] / 2;
        var y = player.pos[1] + player.sprite.size[1] / 2;

        bullets.push({ pos: [x, y],
                       dir: 'forward',
                       sprite: new Sprite('img/sprites.png', [0, 39], [18, 8]) });
        bullets.push({ pos: [x, y],
                       dir: 'up',
                       sprite: new Sprite('img/sprites.png', [0, 50], [9, 5]) });
        bullets.push({ pos: [x, y],
                       dir: 'down',
                       sprite: new Sprite('img/sprites.png', [0, 60], [9, 5]) });


        lastFire = Date.now();
    }
}

Если игрок нажмет «s» или стрелку вниз, мы перемещаем игрока вверх по оси ординат. Система координат canvas имеет координаты (0,0) в верхнем левом углу и поэтому увеличении позиции игрока приводит к снижению положения игрока на экране. Мы сделали тоже самое для все остальных клавиш.
Обратите внимание, что мы определили playerSpeed в начале app.js. Вот скорости которые мы задали:

// Speed in pixels per second
var playerSpeed = 200;
var bulletSpeed = 500;
var enemySpeed = 100;

Умножая playerSpeed с параметром dt, мы считаем сумму пикселей для перемещение по фрейму. Если прошла одна секунда с последнего обновления то игрок продвинется на 200 пикселей, если 0,5 то на 100. Это показывается как постоянная скорость передвижения зависит от частоты кадров.
Последнее что мы сделаем, это выстрел пули, при условиях: был нажат пробел и это произошло большее чем 100 миллисекунд с последнего выстрела. lastFire — это глобальная переменная и является частью состояния игры. Она помогает нам контролировать частоту выстрелов, иначе игрок мог бы стрелять каждый кадр. А это очень легко, правда?!

var x = player.pos[0] + player.sprite.size[0] / 2;
var y = player.pos[1] + player.sprite.size[1] / 2;

bullets.push({ pos: [x, y],
               dir: 'forward',
               sprite: new Sprite('img/sprites.png', [0, 39], [18, 8]) });
bullets.push({ pos: [x, y],
               dir: 'up',
               sprite: new Sprite('img/sprites.png', [0, 50], [9, 5]) });
bullets.push({ pos: [x, y],
               dir: 'down',
               sprite: new Sprite('img/sprites.png', [0, 60], [9, 5]) });

lastFire = Date.now();

Мы рассчитываем позицию новых пуль в x и y координатах. Мы добавим к ним позицию игрока, плюс половину его высоты и ширины, чтобы он стрелял из центра корабля.
image
Мы добавляем 3 пули, потому что они стреляют в разных направлениях. Это делает игру легче, потому что игрок не сможет оказаться в ловушке. Для различия сущностей пуль мы добавили свойство 'dir' со значениями: 'forward','up','down'.

Сущности

Все сущности нуждаются в обновлении. У нас есть сущность игрока и 3 массива с сущностями пуль, врагов и взрывов.

function updateEntities(dt) {
    // Update the player sprite animation
    player.sprite.update(dt);

    // Update all the bullets
    for(var i=0; i<bullets.length; i++) {
        var bullet = bullets[i];

        switch(bullet.dir) {
        case 'up': bullet.pos[1] -= bulletSpeed * dt; break;
        case 'down': bullet.pos[1] += bulletSpeed * dt; break;
        default:
            bullet.pos[0] += bulletSpeed * dt;
        }

        // Remove the bullet if it goes offscreen
        if(bullet.pos[1] < 0 || bullet.pos[1] > canvas.height ||
           bullet.pos[0] > canvas.width) {
            bullets.splice(i, 1);
            i--;
        }
    }

    // Update all the enemies
    for(var i=0; i<enemies.length; i++) {
        enemies[i].pos[0] -= enemySpeed * dt;
        enemies[i].sprite.update(dt);

        // Remove if offscreen
        if(enemies[i].pos[0] + enemies[i].sprite.size[0] < 0) {
            enemies.splice(i, 1);
            i--;
        }
    }

    // Update all the explosions
    for(var i=0; i<explosions.length; i++) {
        explosions[i].sprite.update(dt);

        // Remove if animation is done
        if(explosions[i].sprite.done) {
            explosions.splice(i, 1);
            i--;
        }
    }
}

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

switch(bullet.dir) {
case 'up': bullet.pos[1] -= bulletSpeed * dt; break;
case 'down': bullet.pos[1] += bulletSpeed * dt; break;
default:
    bullet.pos[0] += bulletSpeed * dt;
}

Если bullet.dir = 'up', мы передвигаем пулю вниз по оси ординат. Наоборот, если dir = 'down', для значения по-умолчанию, мы передвигаем вдоль оси абсцисс.

// Remove the bullet if it goes offscreen
if(bullet.pos[1] < 0 || bullet.pos[1] > canvas.height ||
   bullet.pos[0] > canvas.width) {
    bullets.splice(i, 1);
    i--;
}

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

Отслеживание столкновений

А теперь за то, чего все боятся: отслеживание столкновений! На самом деле, это не так сложно как кажется, по крайне мере для нашей игры.
Есть 3 типа столкновений, которые мы должны отслеживать:

  1. Враги и пули
  2. Враги и игрок
  3. Игрок и край экрана

Определение 2D столкновений просто:

function collides(x, y, r, b, x2, y2, r2, b2) {
    return !(r <= x2 || x > r2 ||
             b <= y2 || y > b2);
}

function boxCollides(pos, size, pos2, size2) {
    return collides(pos[0], pos[1],
                    pos[0] + size[0], pos[1] + size[1],
                    pos2[0], pos2[1],
                    pos2[0] + size2[0], pos2[1] + size2[1]);
}

Эти 2 функции могли быть объединены в одну, но мне кажется так легче читать. collides принимает координаты верхнего/левого и нижнего/правого углов обоих объектов и проверяет, есть ли какие то пересечения.
Функция boxCollides — это обертка для collides принимающая массивы с положением и размером каждого элемента. В функции используя размеры в рассчитывает абсолютные координаты положения.
А вот и код, который фактически обнаруживает столкновения:

function checkCollisions() {
    checkPlayerBounds();

    // Run collision detection for all enemies and bullets
    for(var i=0; i<enemies.length; i++) {
        var pos = enemies[i].pos;
        var size = enemies[i].sprite.size;

        for(var j=0; j<bullets.length; j++) {
            var pos2 = bullets[j].pos;
            var size2 = bullets[j].sprite.size;

            if(boxCollides(pos, size, pos2, size2)) {
                // Remove the enemy
                enemies.splice(i, 1);
                i--;

                // Add score
                score += 100;

                // Add an explosion
                explosions.push({
                    pos: pos,
                    sprite: new Sprite('img/sprites.png',
                                       [0, 117],
                                       [39, 39],
                                       16,
                                       [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
                                       null,
                                       true)
                });

                // Remove the bullet and stop this iteration
                bullets.splice(j, 1);
                break;
            }
        }

        if(boxCollides(pos, size, player.pos, player.sprite.size)) {
            gameOver();
        }
    }
}

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

  • Удаление врага
  • Добавление очков
  • Добавление сущности взрыва
  • Удаление пули
  • Остановка внутреннего цикла перебора пуль, так как врага уже не существует

Обратите внимание как мы создаём взрыв. Мы создаём объект со свойствами pos и sprite, и указанием 13 фреймов для анимации на спрайт карте. Так же указывает параметр once равны true, чтобы анимация воспроизвелась только один раз.
Посмотрите на эти 3 строчки:

if(boxCollides(pos, size, player.pos, player.sprite.size)) {
    gameOver();
}

Здесь мы проверяем столкновения игрока и врага, и если столкновение есть — игра окончена.
И наконец, давайте поговорим о checkPlayerBounds:

function checkPlayerBounds() {
    // Check bounds
    if(player.pos[0] < 0) {
        player.pos[0] = 0;
    }
    else if(player.pos[0] > canvas.width - player.sprite.size[0]) {
        player.pos[0] = canvas.width - player.sprite.size[0];
    }

    if(player.pos[1] < 0) {
        player.pos[1] = 0;
    }
    else if(player.pos[1] > canvas.height - player.sprite.size[1]) {
        player.pos[1] = canvas.height - player.sprite.size[1];
    }
}

Он просто не даёт игроку выйти за пределы карты, держа его координаты в пределах 0 и canvas.width/canvas.height.

Рендер

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

// Draw everything
function render() {
    ctx.fillStyle = terrainPattern;
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // Render the player if the game isn't over
    if(!isGameOver) {
        renderEntity(player);
    }

    renderEntities(bullets);
    renderEntities(enemies);
    renderEntities(explosions);
};

function renderEntities(list) {
    for(var i=0; i<list.length; i++) {
        renderEntity(list[i]);
    }    
}

function renderEntity(entity) {
    ctx.save();
    ctx.translate(entity.pos[0], entity.pos[1]);
    entity.sprite.render(ctx);
    ctx.restore();
}

Первое что мы делаем — это отрисовка фона. Мы создали фон местности в init функции используя ctx.createPattern, и мы отрисовываем фон устанавливая fillStyle и вызывая функции fillRect.
Затем мы рисуем игрока, все пули, всех врагов и взрывы. renderEntites обходим циклом массивы сущностей и отрисовывает их. renderEntity использует трансформацию canvas для размещения объекта на экране. ctx.save сохраняет текущую трансформацию, а ctx.restore — восстанавливает.
Если вы посмотрите на функцию рендера спрайтов, то увидите что sprite располагается в позиции (0,0), но вызов ctx.translate перемещает объект на нужное место.

Игра окончена

Последнее что мы должны сделать, это обработка окончания игры. Мы должны определить функции gameOver, которая будет показывать экран окончания игры, и ещё reset, которая будет запускать игру снова.

// Game over
function gameOver() {
    document.getElementById('game-over').style.display = 'block';
    document.getElementById('game-over-overlay').style.display = 'block';
    isGameOver = true;
}

// Reset game to original state
function reset() {
    document.getElementById('game-over').style.display = 'none';
    document.getElementById('game-over-overlay').style.display = 'none';
    isGameOver = false;
    gameTime = 0;
    score = 0;

    enemies = [];
    bullets = [];

    player.pos = [50, canvas.height / 2];
};

gameOver показывает экран, определенный в index.html, говорящий «Gane Over» и имеющий кнопку «restart».
reset устанавливает все значения состояния игры на начальные, прячет экран окончания игры, и заново запускает игру.

Заключительная мысль

В этой статье есть много чему стоит научиться, но я надеюсь что разбил её на достаточно простые куски, что бы показать, что создание игры это не так сложно.
Я сосредоточился на использовании низкоуровневого API Canvas, чтобы пролить свет на то, как легко создавать 2D игры в наши дни. Конечно же, есть несколько игровых движков, которые Вы можете использовать для создания чего-то действительно сложного. Многие игровые движки стандартизируют интерфейс для сущностей, и вам остаётся только определить render и update функции для каждого тип и scene manager автоматически вызовет их для все сущностей всех фреймов.

Автор: WildZero

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js