Пишем игру на HTML5/JS


javascript gamedev html5 canvas

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

Towers game 2D

Начало

Далее буду рассказывать только о файле app.js. Разберем его содержимое.

Для плавности анимации будем использовать requestAnimationFrame. Подробно о нем ознакомиться можно здесь

var requestAnimFrame = (function(){
    return window.requestAnimationFrame    ||
        window.webkitRequestAnimationFrame ||
        window.mozRequestAnimationFrame    ||
        window.oRequestAnimationFrame      ||
        window.msRequestAnimationFrame     ||
        function(callback){
            window.setTimeout(callback, 1000 / 60);
        };
})();

Разделим разработку нашей игры на несколько этапов:

  1. Создание и инициализация холста (canvas) на странице
  2. Добавление основной функции-цикла игры
  3. Инициализация и рендер объектов и ресурсов игры
  4. Обработка событий ввода пользователя
  5. Математика и расчет столкновений объектов в игре
  6. Окончание и перезагрузка игры

Этап 1. Создание и инициализация холста

Первым делом что мы должны сделать - это создать canvas элемент и добавить его к тегу body основной страницы игры.

var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
canvas.width = 1024;
canvas.height = 520;
document.body.appendChild(canvas);

Этап 2. Добавление основной функции-цикла

Основной цикл необходим для обновления и рендера игры.

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

    update(dt);
    render();

    lastTime = now;
    requestAnimFrame(main);
}

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

Этап 3. Инициализация и рендер объектов и ресурсов игры

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

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

В функции init загружаем мир и добавлеем хэндлер кнопки reset, после game over.

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

    document.getElementById('play-again').addEventListener('click', function() {
        reset();
    });
    
    reset();
    lastTime = Date.now();
    main();
}

Начальное состояние

var player = {
    pos: [0, 0],
    sprite: new Sprite('img/hero.png', [0, 0], [48, 30], 5, [0, 1, 2, 1]),
        down: new Sprite('img/hero.png', [0, 0], [48, 30], 5, [0, 1, 2, 1]),
        up: new Sprite('img/hero.png', [0, 144], [48, 30], 5, [0, 1, 2, 1]),
        left: new Sprite('img/hero.png', [0, 48], [48, 30], 5, [0, 1, 2, 1]),
        right: new Sprite('img/hero.png', [0, 96], [48, 30], 5, [0, 1, 2, 1])
};

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

var lastTower = 0;
var gameTime = 0;
var isGameOver;
var terrainPattern;

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

Обновление состояния игрового процесса

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

switch (getRandomInt(0,4)) {
    case 0: //left
        enemies.push({
            pos: [0, Math.random() * (canvas.height - 30)],
            sprite: new Sprite('img/spider.png', [0, 0], [40, 30], 5, [0, 1, 2, 1])
        });
    break;
    case 1: //top
        enemies.push({
            pos: [Math.random() * canvas.width, 0],
            sprite: new Sprite('img/spider.png', [0, 0], [40, 30], 5, [0, 1, 2, 1])
        });
    break;
    case 2: //bottom
        enemies.push({
            pos: [Math.random() * canvas.width, canvas.height - 30],
            sprite: new Sprite('img/spider.png', [0, 0], [40, 30], 5, [0, 1, 2, 1])
        });
    break;
    default: //right
        enemies.push({
            pos: [canvas.width, Math.random() * (canvas.height - 30)],
            sprite: new Sprite('img/spider.png', [0, 0], [40, 30], 5, [0, 1, 2, 1])
        });
    break;
}

Здесь же используем sprite.js. Всю функцию можно посмотреть в исходниках.

Этап 4. Обработка событий ввода пользователя

Наш герой должен уметь двигаться вверх, вниз, влево, вправо. Соответственно привожу ниже реализацию данного решения

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

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

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

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

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

if (input.isDown('SPACE') && !isGameOver) {
    var isClosest = false;
    for (var i = 0; i < towers.length; i++) {
        if (Math.abs(player.pos[0] - towers[i].pos[0]) < 50 && 
            Math.abs(player.pos[1] - towers[i].pos[1]) < 50) {
            isClosest = true;
        }
    }
    
    if (!isClosest) {
        towers[lastTower % 3] = {
            pos: [player.pos[0], player.pos[1]],
            lastFire: Date.now(),
            sprite: new Sprite('img/tower.png', [0, 0], [38, 35], 8, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
        };
        lastTower++;
     }
}

Этап 5. Математика и расчет столкновений объектов в игре

Анимация персонажей, математика движения пуль, и логика движения NPC в игре описаны в функции updateEntities. Вот тут как раз нам и потребуются базовые знания линейной алгебры.

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

    if (!isGameOver && Date.now() - tower.lastFire > 500) {
        var pi = Math.PI;
        var x = tower.pos[0] + tower.sprite.size[0] / 2;
        var y = tower.pos[1] + tower.sprite.size[1] / 2;

        bullets.push({
            pos: [x, y],
            k: getRandomArbitrary(-5 * pi, 5 * pi),
            sprite: new Sprite('img/bullet.png', [0, 0], [24, 24]) 
        });
        tower.lastFire = Date.now();
    }
}

Логика обновления анимации спрайтов башни. И создаем патроны для каждой башни в своем массиве.

Динамика пуль башни:

// Update all the bullets
for (var i = 0; i < bullets.length; i++) {
    var bullet = bullets[i];
    var c = dt * bulletSpeed;
    var sin = Math.sin(bullet.k);       
    var cos = Math.cos(bullet.k);

    bullet.pos[0] += sin * c;
    bullet.pos[1] += cos * c;       

    // 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++) {
    var x0 = enemies[i].pos[0];
    var y0 = enemies[i].pos[1];
    var x1 = player.pos[0];
    var y1 = player.pos[1];
    var c = enemySpeed * dt;
    var l = Math.sqrt((x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0));
    
    enemies[i].pos[0] += (x1 - x0) * c / l;
    enemies[i].pos[1] += (y1 - y0) * c / l;
    enemies[i].sprite.update(dt);

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

Полный код функции updateEntities можно посмотреть в исходникак на GitHub.

Математика расчета столкновений хорошо описана в статье автора (раздел Collision Detection) используемого мной 2d бутстрапа.

Этап 6. Game Over и рестарт

Когда пауки доползают до нашего героя наступает конец света игры.

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

Показываем окно GAME OVER и кнопку "Начать заного". Кликаем ее и все начинается сначала :)

function reset() {
    document.getElementById('game-over').style.display = 'none';
    document.getElementById('game-over-overlay').style.display = 'none';
    isGameOver = false;
    gameTime = 0;
    lastTower = 0;
    score = 0;

    towers = [];
    enemies = [];
    bullets = [];

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

Заключение

В итоге, я для себя понял, что в gamedev много плюсов:

Посмотреть исходники можно тут, поиграть здесь.