Практика 3: Покадровая анимация в javascript

Задача:

Создадим персонажа из игры «Червяк Джим» с возможностью управления на стрелки, влево/вправо хождение. Сделаем все это с помощью элемента canvas.

Решение: анимация «Червяк Джим»

Как сделать анимацию в js?

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

Создаем холст

Первым делом нужно создать canvas холст — это современная замена уже устаревшей технологии flash. Например ее используют в youtube для отрисовки видео да и вобщем то много где используют. Вначале создадим html объект и пропишем в стилях черную рамку, чтобы были видны границы:

<canvas id='jim'>Вы используете устаревший браузер</canvas>

<style>
    canvas { border: 1px solid #000; }
</style>

Мы не будем использовать библиотеку jquery, поэтому получим холст по id стандартной функцией js. Во второй строке мы создаем объект холста, он может быть 2d и 3d (с использованием WebGL библиотеки).

var canvas = document.getElementById("jim"),
    context = example.getContext('2d');

Укажем размеры холста:

canvas.width  = 640;
canvas.height = 480;

Вставка изображения в canvas

Сейчас получился простой белый прямоугольник с рамкой. На холсте начальной точкой с координатами x=0, y=0 является верхний левый угол. Ось x по горизонтали, ось y по вертикали.

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

context.drawImage(img, source_x, source_y, source_width, source_height, x, y, width, height);

Позиционирование на холсте

Первая переменная — картинка, далее координаты x и y точки начала, далее идут необязательные переменные: ширина и высота вырезаемой области. Представьте, что вы работаете в какой-нибудь редакторе, вы ткнули мышкой в точку и начали выделять.

Далее идут 4 параметра, которые обозначают x, y, высоту и ширину вставляемого объекта, его можно сжать или растянуть. 

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

var img = new Image();
img.src = "jim.gif";

img.onload = function() {
    context.drawImage(img, 0, 0);
};

Обратите внимание изображение выводим только после оно как оно загрузится (метод onload срабатывает при получении и загрузки изображения браузером). Мы увидим что изображение нарисовано:

Создаем новый класс

Попробовали, отлично! Еще нам нужны координаты позиции героя, а потом будет еще много свойств и методов принадлежащих герою, поэтому создадим новый класс героя СJim. У класса есть метод — конструктор, он вызывается при создании класса. Внесем в конструктор начальные координаты персонажа и загрузку спрайтов.

class CJim {
    
    constructor () {
        this.x = 10;
        this.y = 10;
        this.sprite = new Image();
        
        this.sprite.src = "jim.gif";
    }
}

Добавим в наш класс метод отрисовки изображения draw. Вот как выглядит весь класс целиком, далее мы будем этот класс расширять (т.е. вносить новые свойства и методы):

class CJim {
    
    constructor () {
        this.x = 10;
        this.y = 10;
        this.sprite = new Image();
        
        this.sprite.src = "jim.gif";
    }
    
    draw () {
        context.drawImage(this.sprite, 0, 0);
    }
}

Обратите внимание внутри класса все переменные вызываются через объект this — этот объект содержить все свойства (например координаты персонажа) и методы (например отрисовка) принадлежащие данному классу.

Класс должен существовать до его вызова. Поэтому поместим его в самое начало кода. Теперь нужно этот класс создать и вызвать его свойство draw. Вот вторая часть нашего кода:

var canvas = document.getElementById("jim"),
    context = canvas.getContext('2d');
    
    canvas.width  = 650;
    canvas.height = 480;
        
    var Jim = new CJim();
    
    window.onload = function() {
        Jim.draw();
    };

Координаты для покадровой анимации

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

Супер, теперь нужно вырезать первый кадр анимации положения «стоять». Тут нужно по пикселям вымерять прямоугольник, ориентироваться буду по нижней границе ног. В paint (или любом другом графическом редакторе) выделим нужный кадр и посмотрим размер.

38 х 51 — это и есть наши ширина и высота. Осталось узнать стартовую точку.

Подставляем полученные значения и получаем первый кадр нашей анимации.

draw () {
    context.drawImage(this.sprite, 16, 12, 38, 51, this.x, this.y, 38, 51);
}

Как видите все сработало весьма привычным образом, квадратик выделен, потом скопирован и вставлен в указанное место. Красной точкой выделена координата 10, 10 — это наше положение объекта.

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

stand: [
    {x:16, y:12, w:38, h:51},
    {x:70, y:6, w:37, h:56},
    {x:123, y:10, w:38, h:52},
    {x:176, y:12, w:37, h:50},
    {x:234, y:14, w:37, h:48},
    {x:289, y:11, w:37, h:51},
]

Проигрывание анимации в javascript

Теперь чтобы картинка менялась нужно отрисовывать кадры по очереди в определенный промежуток времени. У нас в распоряжении есть 2 функции setInterval и setTimeout. setTimeout — вызывается один раз, setInterval — будет вызываться каждый раз интервально, как раз она нам подходит. Первым аргументом она принимает функцию которую нужно выполнить, вторым интервал времени в миллисекундах (1 сек = 1000мс). Завернем в этот интервал нашу функцию отрисовки.

window.onload = function() {

    setInterval ( function () {
        Jim.draw();
    }, 100);

};

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

this.animation = {
    play: 'stand',
    frame: 0,
    stand: [
        ...
    ]
};

this.animation — объект класса CJim, создается в методе с constructor. Его параметр play хранит имя воспроизводимого массива, например stand. frame — это текущий кадр который надо отрисовать. И наконец stand — наш массив кадров.

Теперь внимательно!

В методе draw получим по очереди
1) Имя анимации.
2) Далее по имени анимации нужно получить массив координат, в js к объекту можно обратиться через точку или через квадратные скобки, т.к. имя объекта хранится в переменной мы можем ее подставить в скобки.
3) Текущий фрейм,
4) Ну и координаты получаем по номеру кадра.

var play = this.animation.play,
      position = this.animation[ play ],
      frame = this.animation.frame,
      coords = position[ frame ];

Т.о. this.animation.play и this.animation[ play ] — разные вещи. По другому их можно представить так:

this.animation["play"]
this.animation["stand"]

или

this.animation.play
this.animation.stand - т.к. stand хранится в this.animation.play.

Итак еще раз:
1) Получаем название (‘stand’)
2) По названию получаем массив (stand: [])
3) Получаем номер кадра (0)
4) По кадру (п.3) из массива (п.2) получаем координаты и размеры вырезаемой области.

Теперь заменим значения в функции отрисовки:

context.drawImage(this.sprite, coords.x, coords.y, coords.w, coords.h, this.x, this.y, coords.w, coords.h);

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

if (this.animation.frame+1<position.length)
    this.animation.frame++;
else
    this.animation.frame = 0;

position.length — получаем длину массива, она у нас 6 кадров. Так же помним, что массивы начинаются с 0, поэтому массив у нас 0,1,2,3,4,5 — это 6 элементов. Чтобы уровнять следует текущему фрейму прибавить единичку, только для сравнения.

Также обратите внимание вот на что, конкатенация ++ сохраняет значение в текущую переменную, а + прибавляет и отдает значение. Это разные вещи. Т.е. x++ это x = x+1. Так что если написать в сравнении this.animation.frame++<position.length вместо this.animation.frame+1<position.length, то вы прибавите единицу «навсегда», а не временно.

Ну и вот что мы получим в результате:

Очистка холста canvas

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

context.clearRect(0, 0, canvas.width, canvas.height);

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

this.x = 100;
this.y = 100;
...
context.drawImage(this.sprite, coords.x, coords.y, coords.w, coords.h, this.x, this.y-coords.h, coords.w, coords.h);

Движение объектов

Добавим физику. Нам известны размеры холста. На персонажа должна действовать сила притяжения. Значит, если его нижняя граница не соприкасается с землей, то пусть его положение по Y увеличивается (не забываем положение осей + у Y внизу, а у Х справа). Добавим классу Джима новый метод move. Он будет отвечать за перемещение персонажа. Если Y персонажа меньше (вверху) чем нижняя граница, то прибавляем к Y.

move () {
    if (this.y < canvas.height) 
        this.y++;
}

Теперь герой плавно падает вниз, это слишком медленно, давайте прибавим персонажу веса. Пусть он падает скажем со скоростью 10px за кадр. Создадим переменную speed и установим в нее вес.

this.speed = 10;

Теперь будем прибавлять вес этот вес при перемещении.

this.y+=this.speed;

Покадровая анимация падения

Хорошо бы добавить анимацию падения. Создадим новый массив down с кадрами падения, как это мы делали с положением «стоя». Вот какой массив у меня вышел:

down: [
    {x:16, y:2734, w:43, h:64},
    {x:67, y:2731, w:42, h:68},
    {x:123, y:2740, w:44, h:60},
    {x:181, y:2743, w:45, h:56},
    {x:242, y:2743, w:46, h:56},
    {x:302, y:2741, w:48, h:58},
    {x:364, y:2737, w:45, h:62},
],

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

move () {
    if (this.y < canvas.height) {
        this.animation.play = 'down';
        this.y+=this.speed;
    } else {
        this.animation.play = 'stand';
    }
}

Удаление хромакея в canvas

Давайте наконец уберем этот надоедливый хромакей. Напишем функцию, которая будет удалять зеленые точки. Идея проста, мы перебираем область кадра и если натыкаемся на точку заданного цвета, удаляем ее. Для этого определим rgb значения. Это 80,192,64 соответственно.

chromakey (x,y,w,h) {
    for (var i=x; i<(x+w); i++ ) {
    for (var j=y; j<(y+h); j++ ) {
        var c = context.getImageData(i, j, 1, 1).data;
        if (c[0]==80 && c[1]==192 && c[2]==64)
        {
            context.clearRect(i, j, 1, 1);
        }
    }
    }
}

Данный метод принимает 4 параметра — те же что и у кадра. Создаем 2 цикла, чтобы перебрать все возможные точки (порядок нам не важен). Внутри цикла получаем данные текущей точки — имеет 4 параметра: красный от 0-255, зеленый, синий и прозрачность. И проверяем на совпадение по всем. Если подходит удаляем пиксель.

Вызываем функцию сразу после отрисовки в методе draw:

context.drawImage(this.sprite, coords.x, coords.y, coords.w, coords.h, this.x, this.y-coords.h, coords.w, coords.h);

context.drawImage(this.sprite, coords.x, coords.y, coords.w, coords.h, this.x, this.y-coords.h, coords.w, coords.h);

this.chromakey(this.x, this.y-coords.h, coords.w, coords.h);

Анимация хождения

Научим Джима ходить! Создадим промежуточную переменную в классе CJim scale которая будет хранить направление движения: 0 — не двигается, +1 движение вправо, -1 движение влево. По умолчанию 0.

this.scale = 0;

Будем «слушать» 2 события нажатие кнопки и отжатие кнопки.

document.addEventListener('keydown', function(event) {
   ...
});
document.addEventListener('keyup', function(event) {
    ...
});

Если нажали кнопку то поменялся scale на 1 или -1 в зависимости от кнопки, если отжали, то на 0.

document.addEventListener('keydown', function(event) {
    if (event.code == 'ArrowRight')
        Jim.scale = 1;
    if (event.code == 'ArrowLeft')
        Jim.scale = -1;
});
document.addEventListener('keyup', function(event) {
    Jim.scale = 0;
});

В класс move пишем проверку scale если он отличен от нуля то перемещаемся по X, скорость speed умноженного на направление движения scale.
вправо x = x + (speed*1)
влево x = x + (speed*-1) или x = x — speed,

Ну и давайте сразу установим анимацию, назовем массив go как и другие разы покадрово накидаю туда картинок.

if (this.scale != 0) {
    this.animation.play = 'go';
    this.x += this.speed * this.scale;
}

Как видите начинает пропадать, это баг его надо исправить.

Как найти баг?

Подставляя в разные места конструкцию console.log() с разными параметрами я обнаружил, что в момент, когда я отпускаю клавишу название анимации меняется, а вот значение frame остается прежним. Это значит что при ходьбе у массива больше кадров, например воспроизводится 7, а отпустив клавишу программа пытается воспроизвести 7 кадр уже не массива go а массива stand. В нем всего 5 кадров, 7 естественно не находится и пишется ошибка.

Напишем затычку:

Если нет нужного кадра то проигрывать первый, т.е. с индексом 0, и подставим затычку до отрисовки.

if (coords==undefined) coords = position[ 0 ];

context.drawImage(this.sprite, coords.x, coords.y, coords.w, coords.h, this.x, this.y-coords.h, coords.w, coords.h);
this.chromakey(this.x, this.y-coords.h, coords.w, coords.h);

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

Запретим хождение во время падения:

if (this.scale !== 0) {
    if (this.animation.play != 'down') this.animation.play = 'go';
    this.x += this.speed*this.scale;
}

Ну и пожалуй достаточно. Дальнейшее углубление в тему canvas предлагаю провести самостоятельно. Вот что у нас вышло:

Задание:

Попробуйте самостоятельно написать функцию приседания и функцию прыжка.

Решение: анимация «Червяк Джим»