Перемещение персонажа на спрайтах с помощью JavaScript часть 1
Перевод статьи: Martin Himmel — Moving a Sprite Sheet Character with JavaScript
В статье рассматривается создание анимации с помощью спрайтов на HTML5 canvas в JavaScript.
Начало
Первым делом создадим элемент canvas.
<canvas width="300" height="200"></canvas>
Добавим бордюр (чтобы мы могли видеть нашу полезную площадь).
canvas { border: 1px solid black; }
Загрузим лист спрайтов (https://opengameart.org/content/green-cap-character-16×18). Пока мы на этом шаге, давайте сразу получим доступ к canvas и его 2D-context.
let img = new Image(); img.src = 'https://opengameart.org/sites/default/files/Green-Cap-Character-16x18.png'; img.onload = function() { init(); }; let canvas = document.querySelector('canvas'); let ctx = canvas.getContext('2d'); function init() { // future animation code goes here }
Функция init вызывается сразу после загрузки изображения через img.onload. Это необходимо для того, чтобы изображение было загружено, прежде чем мы попытаемся с ним работать. Весь код анимации войдет в функцию init. Для учебных целей этого урока это нормально. Если бы мы имели дело с несколькими изображениями, то, вероятно, лучше бы использовать Promises, чтобы дождаться загрузки всех изображений, прежде чем что-либо делать с ними.
Spritesheet
Теперь, когда мы настроены, давайте посмотрим на изображение.
Каждая строка представляет цикл анимации. Первый (верхний) ряд — это персонаж, идущий в нисходящем направлении, второй ряд — идущий вверх, третий ряд — идущий влево, а четвертый (нижний) ряд — идущий вправо. Технически левый столбец — это постоянный (без анимации), а средний и правый столбцы — это кадры анимации. Я думаю, что мы можем использовать все три для более плавной анимации ходьбы. 😊
Метод drawImage
Прежде чем мы перейдем к анимации нашего изображения, давайте посмотрим на метод drawImage, который мы будем использовать для автоматического нарезания листа спрайтов и применения его к нашему canvas.
В этом методе много параметров! Особенно третья группа, которую мы будем использовать. Но не волнуйтесь, все не так плохо, как кажется. Все они логически сгруппированы.
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
Аргумент image
— это исходное изображение. Следующие четыре (sx, sy, sWidth и sHeight) относятся к исходному изображению — листу спрайтов (sprite sheet). Последние четыре (dx, dy, dWidth и dHeight) относятся к месту назначения — canvas.
Параметры «x» и «y» (sx, sy, dx, dy) относятся к начальным положениям листа спрайтов (источника) и canvas (назначения) соответственно. По сути, это сетка, где верхний левый угол начинается с (0, 0) и перемещается вправо и вниз. Другими словами, (50, 30) — это 50 пикселей вправо и 30 пикселей вниз.
Параметры «Width» и «Height» (sWidth, sHeight, dWidth и dHeight) относятся к ширине и высоте листа спрайтов и canvas, начиная с их соответствующих позиций «x» и «y». Разберем его на один секцию, скажем, на исходное изображение. Если исходные параметры (sx, sy, sWidth, sHeight) равны (10, 15, 20, 30), начальная позиция (в координатах сетки) будет (10, 15) и растянута до (30, 45). Затем конечные координаты вычисляются как (sx + sWidth, sy + sHeight).
Отрисовка первого кадра
Теперь, когда мы рассмотрели метод drawImage, давайте посмотрим на него в действии.
Размер картинки персонажа в листе спрайтов удобно обозначен в имени файла (16×18), что дает нам наши атрибуты ширины и высоты. Первый кадр начинается с (0, 0) и заканчивается (16, 18). Нарисуем это на canvas. Мы начнем с рисования этой рамки, начиная с точки (0, 0) на canvas, и сохраним пропорции.
function init() { ctx.drawImage(img, 0, 0, 16, 18, 0, 0, 16, 18); }
И у нас есть первый кадр! Хотя персонаж выглядит немного маленьким. Давайте немного увеличим масштаб, чтобы было его легче разглядеть.
Внесем следующие изменения:
const scale = 2; function init() { ctx.drawImage(img, 0, 0, 16, 18, 0, 0, 16 * scale, 18 * scale); }
Вы должны увидеть, что изображение, нарисованное на canvas, увеличилось вдвое как по горизонтали, так и по вертикали. Изменяя значения dWidth и dHeight, мы можем масштабировать исходное изображение, чтобы оно было больше или меньше на холсте. Будьте осторожны при этом, поскольку вы имеете дело с пикселями, они могут довольно быстро начать размываться. Попробуйте изменить значение scale
и посмотрите, как изменится вывод.
Следующий кадр
Чтобы нарисовать второй кадр, единственное, что нам нужно сделать, это изменить некоторые значения для исходного набора. В частности, sx и sy. Ширина и высота каждого кадра одинаковы, поэтому нам никогда не придется изменять эти значения. Фактически, давайте вытащим эти значения, создадим пару масштабированных значений и нарисуем наши следующие два кадра справа от текущего кадра.
const scale = 2; const width = 16; const height = 18; const scaledWidth = scale * width; const scaledHeight = scale * height; function init() { ctx.drawImage(img, 0, 0, width, height, 0, 0, scaledWidth, scaledHeight); ctx.drawImage(img, width, 0, width, height, scaledWidth, 0, scaledWidth, scaledHeight); ctx.drawImage(img, width * 2, 0, width, height, scaledWidth * 2, 0, scaledWidth, scaledHeight); }
А сейчас это выглядит так:
Теперь у нас есть весь верхний ряд таблицы спрайтов, но в трех отдельных кадрах. Если вы посмотрите на вызовы ctx.drawImage, то сейчас меняются только 4 значения — sx, sy, dx и dy.
Немного упростим. Пока мы на этом шаге, давайте начнем использовать номера кадров из таблицы спрайтов вместо пикселей.
Замените все вызовы ctx.drawImage следующим образом:
function drawFrame(frameX, frameY, canvasX, canvasY) { ctx.drawImage(img, frameX * width, frameY * height, width, height, canvasX, canvasY, scaledWidth, scaledHeight); } function init() { drawFrame(0, 0, 0, 0); drawFrame(1, 0, scaledWidth, 0); drawFrame(0, 0, scaledWidth * 2, 0); drawFrame(2, 0, scaledWidth * 3, 0); }
Наша функция drawFrame обрабатывает математику таблицы спрайтов, поэтому нам нужно передать только номера кадров (начиная с 0, как массив, поэтому кадры «x» равны 0, 1 и 2).
Значения canvas «x» и «y» по-прежнему принимают значения пикселей, поэтому мы можем лучше контролировать позиционирование персонажа. Перемещение множителя scaledWidth внутри функции (например, scaledWidth * canvasX) будет означать, что все что перемещается изменяет масштабируемую ширину за раз. Но это не сработает с анимацией ходьбы, если, скажем, персонаж перемещается на 4 или 5 пикселей за каждый кадр. Так что оставим все как есть.
В этом списке вызовов drawFrame также есть дополнительная строка. Это сделано для того, чтобы показать, как будет выглядеть наш цикл анимации, а не просто нарисовать три верхних кадра листа спрайтов. Вместо того, чтобы цикл анимации повторял «левый шаг, правый шаг», он будет повторять «стоять, влево, стоять, вправо» — это немного лучше. В некоторых играх 80-х использовалась двухэтапная анимация.
Вот что у нас есть на данный момент:
Давайте оживим этого персонажа!
Теперь мы готовы оживить нашего персонажа! Давайте посмотрим в документации MDN на requestAnimationFrame.
Это то, что мы будем использовать для создания нашего цикла. Мы также могли бы использовать setInterval, но в requestAnimationFrame уже есть несколько хороших оптимизаций, например, работа со скоростью 60 кадров в секунду (или как можно более близкой) и остановка цикла анимации, когда браузер или вкладка теряет фокус.
По сути, requestAnimationFrame является рекурсивной функцией. Итак чтобы создать наш цикл анимации, мы снова вызовем requestAnimationFrame из функции, которую мы передаем в качестве аргумента. Напишем что то вроде этого:
window.requestAnimationFrame(step); function step() { // do something window.requestAnimationFrame(step); }
Единственный вызов перед тем, как функция walk запускает цикл, затем он постоянно вызывается внутри.
Прежде чем мы перейдем к его использованию, нам нужно знать и использовать еще один контекстный метод — clearRect (документация MDN). Если при рисовании на canvas мы продолжаем вызывать drawFrame в той же позиции, он будет продолжать рисовать поверх того, что уже есть. Для простоты мы будем очищать весь canvas между каждой прорисовкой, а не только область, в которой мы рисуем.
Итак, наш цикл рисования будет выглядеть примерно так: очистить, нарисовать первый кадр, очистить, нарисовать второй кадр и так далее.
Другими словами:
ctx.clearRect(0, 0, canvas.width, canvas.height); drawFrame(0, 0, 0, 0); // repeat for each frame
Итак, давайте оживим нашего персонажа! Давайте создадим массив для цикла цикла (0, 1, 0, 2) и что-нибудь, чтобы отслеживать, где мы находимся в этом цикле. Затем мы создадим нашу пошаговую функцию, которая будет выступать в качестве основного цикла анимации.
Функция step очищает canvas, отрисовывает кадр, продвигает (или сбрасывает) нашу позицию в цикле, а затем вызывает себя через requestAnimationFrame.
const cycleLoop = [0, 1, 0, 2]; let currentLoopIndex = 0; function step() { ctx.clearRect(0, 0, canvas.width, canvas.height); drawFrame(cycleLoop[currentLoopIndex], 0, 0, 0); currentLoopIndex++; if (currentLoopIndex >= cycleLoop.length) { currentLoopIndex = 0; } window.requestAnimationFrame(step); }
И чтобы запустить анимацию, давайте обновим функцию init.
function init() { window.requestAnimationFrame(step); }
Наш персонаж начал очень быстро двигаться!😂
Притормозим его!
Похоже, наш персонаж немного вышел из-под контроля. Если браузер позволяет это, персонаж будет отрисовываться со скоростью 60 кадров в секунду или как можно ближе. Давайте ограничим это так, чтобы он выполнялся каждые 15 кадров. Нам нужно будет отслеживать, на каком кадре мы находимся. Затем в пошаговой функции мы будем увеличивать счетчик при каждом вызове, но отрисовывать только после прохождения 15 кадров. По прошествии 15 кадров сбросим счетчик и отрисуем кадр.
const cycleLoop = [0, 1, 0, 2]; let currentLoopIndex = 0; let frameCount = 0; function step() { frameCount++; if (frameCount < 15) { window.requestAnimationFrame(step); return; } frameCount = 0; ctx.clearRect(0, 0, canvas.width, canvas.height); drawFrame(cycleLoop[currentLoopIndex], 0, 0, 0); currentLoopIndex++; if (currentLoopIndex >= cycleLoop.length) { currentLoopIndex = 0; } window.requestAnimationFrame(step); }
Намного лучше!
Другие направления
Пока что мы работали только с направлением вниз. Как насчет того, чтобы немного изменить анимацию, чтобы персонаж выполнял полный цикл из 4 шагов в каждом направлении?
Помните, что «нижние» кадры находятся в строке 0 в нашем коде (первая строка таблицы спрайтов), вверх — в строке 1, слева — в строке 2, а справа — в строке 3 (нижней строке таблицы спрайтов). В цикле остается 0, 1, 0, 2 для каждой строки. Поскольку мы уже обрабатываем изменения цикла, единственное, что нам нужно изменить, — это номер строки, который является вторым параметром функции drawFrame.
Мы добавим переменную, чтобы отслеживать наше текущее направление. Чтобы не усложнять, мы будем располагаться в листе спрайтов в порядке (вниз, вверх, влево, вправо), чтобы он был последовательным (0, 1, 2, 3, повтор).
Когда цикл сбросится, мы перейдем к следующему направлению. И как только мы пройдем все направления, мы начнем сначала. Итак, наша обновленная пошаговая функция и связанные с ней переменные выглядят так:
const cycleLoop = [0, 1, 0, 2]; let currentLoopIndex = 0; let frameCount = 0; let currentDirection = 0; function step() { frameCount++; if (frameCount < 15) { window.requestAnimationFrame(step); return; } frameCount = 0; ctx.clearRect(0, 0, canvas.width, canvas.height); drawFrame(cycleLoop[currentLoopIndex], currentDirection, 0, 0); currentLoopIndex++; if (currentLoopIndex >= cycleLoop.length) { currentLoopIndex = 0; currentDirection++; // Next row/direction in the sprite sheet } // Reset to the "down" direction once we've run through them all if (currentDirection >= 4) { currentDirection = 0; } window.requestAnimationFrame(step); }
И вот оно! Наш персонаж ходит во всех четырех направлениях, и все анимировано из одного изображения.
Почему вы не добавили показ анимации? Вдруг ваш код неверен
надо всем преподам так делать, а то бывает, что код есть, а он не работает
Everything is working -like.