Учебник по созданию игры на JavaScript – создайте клон Stick Hero с помощью HTML Canvas + JavaScript

В этом уроке вы узнаете, как создать игру, вдохновленную Stick Hero, с помощью простого JavaScript и HTML canvas. Мы собираемся воссоздать Stick Hero [https//apps.apple.com/us/app/stick-hero/id918338898], мобильную игру от KetchApp. Мы изучим, как работает игра в целом, как использовать JavaScript для...

В данном уроке вы узнаете, как создать игру, вдохновленную Stick Hero, используя обычный JavaScript и HTML canvas.

Мы собираемся воссоздать игру Stick Hero, мобильную игру, опубликованную KetchApp. Мы расскажем о том, как работает игра в общем, как использовать JavaScript для рисования на элементе <canvas>, как добавить игровую логику и анимировать игру, а также о том, как работает обработка событий.

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

На протяжении урока мы будем использовать JavaScript для управления состоянием игры и HTML элементом <canvas> для визуализации игровой сцены. Чтобы получить максимум пользы от этого урока, вам следует иметь базовое понимание JavaScript. Но даже если вы новичок, вы все равно сможете следить за нами и учиться по ходу дела.

Давайте начнем и создадим нашу собственную игру Stick Hero с использованием JavaScript и HTML canvas!

Если вы предпочитаете видеоформат, вы также можете посмотреть это руководство на YouTube.

Оглавление

  1. Игра Stick Hero
  2. Фазы игры
  3. Основные части игры
  4. Как инициализировать игру
  5. Функция отрисовки
  6. Обработка событий
  7. Основной цикл анимации
  8. Выводы

Игра Stick Hero

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

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

Вы также можете ознакомиться с оригинальной игрой как на iOS, так и на Android.

https://codepen.io/HunorMarton/embed/preview/xxOMQKg

Фазы игры

Игра имеет пять различных фаз, которые повторяются снова и снова, пока герой не упадет.

  1. Вначале игра находится в ожидании ввода пользователя, и ничего не происходит.
  2. Затем, когда игрок удерживает кнопку мыши, игра растягивает палку вверх, пока кнопка мыши не отпущена.
  3. Затем, когда кнопка мыши отпускается, палка начинает поворачиваться и падать вниз, надеемся, на следующую платформу.
  4. Если так происходит, то герой идет по палке на следующую платформу.
  5. Наконец, когда герой достигает следующей платформы, вся сцена переходит влево, чтобы сосредоточиться на герое и следующей платформе. Затем весь цикл начинается сначала. Игра ожидает ввода пользователя, и когда игрок удерживает кнопку мыши, рисуется новая палка.

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

Stick-Hero
Фазы игры

Основные части игры

Как мы реализуем это в коде? Эта игра включает в себя три основные части. Состояние игры, функция draw и функция animate.

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

let phase = "waiting"; // waiting | stretching | turning | walking | transitioning | fallinglet lastTimestamp; // Временная метка предыдущего цикла анимацииlet heroX; // Изменяется при движении впередlet heroY; // Изменяется только при паденииlet sceneOffset; // Передвигает всю игруlet platforms = [];let sticks = [];let score = 0;...

Затем у нас будет две основные функции: одна, которая рисует сцену на экране на основе этого состояния (это будет функция draw), и одна, которая будет постепенно изменять это состояние так, чтобы оно выглядело как анимация (это будет функция animate). Наконец, у нас также будет обработка событий, которая запустит цикл анимации.

Как инициализировать игру

Чтобы начать, давайте инициализируем проект с помощью простого файла HTML, CSS и JavaScript. Мы определим структуру кода, а затем инициализируем состояние игры.

HTML

Часть HTML этой игры очень простая. Большая часть игры будет находиться внутри элемента <canvas>. Мы будем использовать JavaScript для рисования на этом холсте. У нас также есть элемент div, который будет отображать счет и кнопку для перезапуска.

В заголовке мы также загружаем наши файлы CSS и JavaScript. Обратите внимание на тег defer при загрузке скрипта. Это выполнит скрипт только после загрузки остальной части HTML, чтобы мы могли сразу же получить доступ к частям HTML (например, элементу canvas) в нашем скрипте.

<!DOCTYPE html><html>  <head>    <title>Stick Hero</title>    <link rel="stylesheet" href="index.css" />    <script src="index.js" defer></script>  </head>  <body>    <div class="container">      <canvas id="game" width="375" height="375"></canvas>      <div id="score"></div>      <button id="restart">ПЕРЕЗАПУСК</button>    </div>  </body></html>

CSS

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

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

html,body {  height: 100%;}body,.container {  display: flex;  justify-content: center;  align-items: center;}.container {  position: relative;  font-family: Helvetica;}canvas {  border: 1px solid;}#score {  position: absolute;  top: 30px;  right: 30px;  font-size: 2em;  font-weight: 900;}#restart {  position: absolute;  display: none;}

Структура нашего JavaScript-файла

И, наконец, часть JavaScript, где происходит вся магия. Для простоты я поместил все в один файл, но не стесняйтесь разделить его на несколько файлов.

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

  • Мы определяем различные переменные, которые вместе составляют состояние игры. Больше о их значениях в разделе о том, как инициализировать состояние.
  • Мы собираемся определить несколько переменных как configuration, например, размер платформ и скорость движения героя. Мы рассмотрим их в разделе о рисовании и основном цикле.
  • Ссылка на элемент <canvas> в HTML и получение контекста рисования этого элемента. Это будет использоваться функцией draw.
  • Ссылка на элемент score и кнопку restart в HTML. Мы будем обновлять счет каждый раз, когда герой переходит на новую платформу. И мы покажем кнопку сброса, когда игра закончится.
  • Мы инициализируем состояние игры и рисуем сцену, вызывая функцию resetGame. Это единственный вызов функции верхнего уровня.
  • Мы определяем функцию draw, которая будет рисовать сцену на элементе canvas на основе состояния.
  • Мы настраиваем обработчики событий для событий mousedownи mouseup.
  • Мы определяем функцию animate, которая будет изменять состояние.
  • У нас также будет несколько вспомогательных функций, о которых мы поговорим позже.
// Состояние игрыlet phase = "waiting"; // waiting | stretching | turning | walking | transitioning | falling
let lastTimestamp; // Временная метка предыдущего анимационного цикла
let heroX; // Изменяется при движении вперед
let heroY; // Изменяется только при падении
let sceneOffset; // Двигает всю игру
let platforms = [];
let sticks = [];
let score = 0;

// Конфигурация...

// Получение элемента холста
const canvas = document.getElementById("game");
// Получение контекста рисования
const ctx = canvas.getContext("2d");

// Другие элементы пользовательского интерфейса
const scoreElement = document.getElementById("score");
const restartButton = document.getElementById("restart");

// Начало игры
resetGame();

// Сброс состояния и макета игры
function resetGame() {
  ...
  draw();
}

// Рисование игры
function draw() {
  ...
}

// Обработчики событий мыши
window.addEventListener("mousedown", function (event) {
  ...
});

window.addEventListener("mouseup", function (event) {
  ...
});

// Анимация
function animate(timestamp) {
  ...
}

Инициализация Состояния

Для запуска игры мы вызываем ту же функцию, которую используем для её сброса – функцию resetGame. Она инициализирует/сбрасывает состояние игры и вызывает функцию draw для отрисовки сцены.

Состояние игры включает следующие переменные:

  • phase: Текущая фаза игры. Начальное значение – waiting.
  • lastTimestamp: Используется функцией animate для определения, сколько времени прошло с последнего анимационного цикла. Подробнее об этом будет рассказано позже.
  • platforms: Массив, содержащий метаданные каждой платформы. Каждая платформа представлена объектом с свойствами x и w, представляющими их позицию по оси X и ширину. Первая платформа всегда одинаковая – задается здесь – чтобы убедиться, что она имеет разумный размер и позицию. Следующие платформы генерируются с помощью вспомогательной функции по мере продвижения игры.
  • heroX: Позиция героя по оси X. По умолчанию герой находится близко к краю первой платформы. Это значение будет изменяться во время фазы движения.
  • heroY: Позиция героя по оси Y. По умолчанию она равна нулю. Она меняется только при падении героя.
  • sceneOffset: При движении героя вперед мы должны сдвигать весь экран назад, чтобы герой оставался по центру экрана. В противном случае герой уйдет с экрана. В этой переменной мы отслеживаем, насколько должны сдвигать экран назад. Значение по умолчанию – 0.
  • sticks: Метаданные палок. Хотя герой может растягивать только одну палку за раз, мы также должны хранить предыдущие палки, чтобы иметь возможность их отрисовывать. Поэтому переменная sticks также является массивом. Каждая палка представлена объектом с свойствами x, length и rotation. Свойство x представляет начальную позицию палки, которая всегда совпадает с верхним правым углом соответствующей платформы. Свойство length будет увеличиваться в фазе растяжения, а свойство rotation будет меняться от 0 до 90 в фазе поворота, или от 90 до 180 в фазе падения. Изначально массив sticks содержит одну ‘невидимую’ палку с нулевой длиной. Каждый раз, когда герой достигает новой платформы, в массив добавляется новая палка.
  • score: Счет игры. Показывает, сколько платформ достиг герой. По умолчанию равен 0.
function resetGame() {
  // Сброс состояния игры
  phase = "waiting";
  lastTimestamp = undefined;
  
  // Первая платформа всегда одинаковая
  platforms = [{ x: 50, w: 50 }];
  generatePlatform();
  generatePlatform();
  generatePlatform();
  generatePlatform();

  // Инициализация позиции героя
  heroX = platforms[0].x + platforms[0].w - 30; // Герой стоит немного перед краем
  heroY = 0;

  // На сколько должны сдвигать экран назад
  sceneOffset = 0;

  // Всегда есть палка, даже если она кажется невидимой (длина: 0)
  sticks = [{ x: platforms[0].x + platforms[0].w, length: 0, rotation: 0 }];

  // Счет
  score = 0;

  // Сброс пользовательского интерфейса
  restartButton.style.display = "none"; // Скрыть кнопку сброса
  scoreElement.innerText = score; // Сброс отображения счета

  draw();
}

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

После инициализации состояния игры и сброса пользовательского интерфейса функция resetGame вызывает функцию draw для первой отрисовки экрана.

Функция resetGame вызывает вспомогательную функцию, которая генерирует случайную платформу. В этой функции мы определяем минимальное расстояние между двумя платформами (minimumGap) и максимальное расстояние (maximumGap). Мы также определяем минимальную ширину платформы и максимальную ширину.

Исходя из этих диапазонов и существующих платформ, мы генерируем метаданные новой платформы.

function generatePlatform() {  const minimumGap = 40;  const maximumGap = 200;  const minimumWidth = 20;  const maximumWidth = 100;  // X-координата правого края самой дальней платформы  const lastPlatform = platforms[platforms.length - 1];  let furthestX = lastPlatform.x + lastPlatform.w;  const x =    furthestX +    minimumGap +    Math.floor(Math.random() * (maximumGap - minimumGap));  const w =    minimumWidth + Math.floor(Math.random() * (maximumWidth - minimumWidth));  platforms.push({ x, w });}

Функция отрисовки

Функция draw отрисовывает весь холст на основе состояния. Она сдвигает все пользовательский интерфейс на указанное смещение, располагает героя на позиции и рисует платформы и столбы.

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

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

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

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

Мы определили элемент <canvas> в HTML. Но как нам нарисовать на нем что-то? В JavaScript первым делом мы получаем элемент canvas, а затем получаем его контекст где-то в начале нашего файла. Затем мы можем использовать этот контекст для выполнения команд отрисовки.

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

  • canvasWidth и canvasHeight представляют размер элемента canvas в HTML. Они должны соответствовать тому, что мы установили в HTML. Мы используем эти значения в разных местах.
  • platformHeight представляет высоту платформ. Мы используем эти значения при отрисовке самих платформ, а также при позиционировании героя и столбов.

Функция отрисовки перерисовывает весь экран с нуля каждый раз. Вначале убедимся, что он пустой. Вызов функции clearRect на контексте рисования с правильными аргументами гарантирует удаление всего из него.

...<div class="container">  <canvas id="game" width="375" height="375"></canvas>  <div id="score"></div>  <button id="restart">RESTART</button></div>...
...// Получаем элемент canvasconst canvas = document.getElementById("game");// Получаем контекст рисованияconst ctx = canvas.getContext("2d");...// Конфигурацияconst canvasWidth = 375;const canvasHeight = 375;const platformHeight = 100;...function draw() {  ctx.clearRect(0, 0, canvasWidth, canvasHeight);  ...}...

Как оформить сцену

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

Stick-Hero.001
Standardmäßig befindet sich der Mittelpunkt des Koordinatensystems in der oberen linken Ecke.

Anfangs befindet sich die Koordinate 0, 0 in der oberen linken Ecke des Bildschirms, aber während der Held sich nach vorne bewegt, sollte sich die gesamte Szene nach links verschieben. Andernfalls würden wir das Ende des Bildschirms erreichen.

Während das Spiel fortschreitet, aktualisieren wir den Wert von sceneOffset, um diese Verschiebung in der Haupt-Schleife zu verfolgen. Wir können diese Variable verwenden, um das gesamte Layout zu verschieben. Wir rufen den Befehl translate auf, um die Szene auf der X-Achse zu verschieben.

function zeichnen() {  ctx.clearRect(0, 0, canvasBreite, canvasHöhe);  // Speichern der aktuellen Transformation  ctx.save();  // Verschieben der Ansicht  ctx.translate(-sceneOffset, 0);  // Szene zeichnen  zeichnePlattformen();  zeichneHeld();  zeichneSticks();  // Zurücksetzen der Transformation auf den letzten gespeicherten Zustand  ctx.restore();}

Es ist wichtig, dass wir dies tun, bevor wir etwas auf die Leinwand malen, weil der translate-Befehl eigentlich nichts auf der Leinwand bewegt. Alles, was wir zuvor auf die Leinwand gemalt haben, bleibt so, wie es war.

Stattdessen verschiebt der translate-Befehl das Koordinatensystem. Die Koordinate 0, 0 wird nicht mehr in der oberen linken Ecke sein, sondern außerhalb des linken Bildschirmrandes. Alles, was wir danach malen, wird entsprechend diesem neuen Koordinatensystem gemalt.

Genau das wollen wir. Wenn wir im Spiel voranschreiten, wird der Held seine X-Koordinate erhöhen. Indem wir das Koordinatensystem nach hinten verschieben, stellen wir sicher, dass er innerhalb des Bildschirms gemalt wird.

Stick-Hero.002
Wenn wir den translate-Befehl verwenden, wird sich der Mittelpunkt des Koordinatensystems nach links verschieben

Die translate-Befehle addieren sich. Das bedeutet, dass, wenn wir den translate-Befehl zweimal aufrufen, der zweite nicht einfach den ersten überschreibt, sondern eine Verschiebung oben auf dem ersten Befehl hinzufügt.

Wir werden die zeichnen-Funktion in einer Schleife aufrufen, daher ist es wichtig, dass wir diese Transformation jedes Mal zurücksetzen, wenn wir zeichnen. Außerdem starten wir immer mit der Koordinate 0, 0 in der oberen linken Ecke. Andernfalls wird das Koordinatensystem unendlich nach links verschoben.

Wir können Transformationen zurücksetzen, indem wir den restore-Befehl aufrufen, sobald wir nicht mehr in diesem verschobenen Koordinatensystem sein möchten. Der restore-Befehl setzt Übergänge und viele andere Einstellungen auf den Zustand zurück, den die Leinwand beim letzten save-Befehl hatte. Deshalb speichern wir oft den Kontext am Beginn eines Malblocks und stellen ihn am Ende wieder her.

Wie man die Plattformen zeichnet

Das war bisher nur die Vorbereitung, aber wir haben noch nichts gemalt. Fangen wir mit einem einfachen an und zeichnen wir Plattformen. Die Metadaten der Plattformen werden im platforms-Array gespeichert. Es enthält die Startposition der Plattform und ihre Breite.

Wir können über dieses Array iterieren und ein Rechteck füllen, indem wir die Startposition, die Breite und die Höhe der Plattform einstellen. Wir tun dies, indem wir die Funktion fillRect aufrufen und die X- und Y-Koordinaten sowie die Breite und Höhe des zu füllenden Rechtecks angeben. Beachte, dass die Y-Koordinate umgekehrt ist – sie wächst von oben nach unten.

Stick-Hero.003
Zeichnen der Plattform
// Beispielzustand der Plattformenlet platforms = [  { x: 50, w: 50 },  { x: 90, w: 30 },];...function zeichnePlattformen() {  platforms.forEach(({ x, w }) => {    // Plattform zeichnen    ctx.fillStyle = "schwarz";    ctx.fillRect(x, canvasHöhe - plattformHöhe, w, plattformHöhe);  });}

Was am Canvas interessant ist, oder zumindest für mich überraschend war, ist, dass du etwas, was du auf das Canvas gemalt hast, nicht mehr ändern kannst. Es ist nicht so, dass du ein Rechteck malst und dann seine Farbe ändern kannst. Sobald etwas auf das Canvas gemalt ist, bleibt es so, wie es ist.

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

Как нарисовать героя

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

Пока что давайте просто используем красный прямоугольник в качестве заполнителя для героя. Снова мы используем функцию fillRect и передаем координаты X и Y, а также ширину и высоту героя.

Позиции X и Y будут основаны на состоянии heroX и heroY. Положение героя X относится к системе координат, но его положение Y относится к верху платформы (оно имеет значение 0, когда находится наверху платформы). Нам нужно скорректировать позицию Y, чтобы она находилась в верхней части платформы.

function drawHero() {
  const heroWidth = 20;
  const heroHeight = 30;
  ctx.fillStyle = "red";
  ctx.fillRect(
    heroX,
    heroY + canvasHeight - platformHeight - heroHeight,
    heroWidth,
    heroHeight
  );
}

Как нарисовать палки

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

Палки хранятся в массиве аналогично платформам, но имеют разные атрибуты. У всех они есть начальная позиция, длина и поворот. Последние два изменения происходят в основном игровом цикле, а первое – позиция – должно соответствовать верхнему правому углу платформы.

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

Мы можем снова использовать команду translate, чтобы установить центр системы координат на край платформы. Затем мы можем использовать команду rotate, чтобы повернуть систему координат вокруг этого нового центра.

Stick-Hero.004
После использования команд translate и rotate система координат будет повернута вокруг нового центра
// Пример состояния палок let sticks = [ { x: 100, length: 50, rotation: 60 }];...function drawSticks() { sticks.forEach((stick) => { ctx.save(); // Переместить точку привязки в начало палки и повернуть ctx.translate(stick.x, canvasHeight - platformHeight); ctx.rotate((Math.PI / 180) * stick.rotation); // Нарисовать палку ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -stick.length); ctx.stroke(); // Восстановить преобразования ctx.restore(); });}

После команд translate и rotate начальная точка палки будет находиться в координате 0, 0, и система координат будет повернута.

В этом примере мы рисуем линию вверх – у неё и начало, и конец имеют одинаковую X-координату. Только Y-координата меняется. Тем не менее, линия направлена вправо, потому что вся система координат повернута. Теперь вверху – в диагональном направлении. Это немного запутанно, но можно привыкнуть.

Stick-Hero.005
Пока мы рисуем линию вдоль оси Y, линия будет выглядеть диагональной из-за преобразованной системы координат

Фактическое рисование линии также интересно. Здесь нет простой команды для рисования линии, поэтому нам нужно нарисовать путь.

Мы получаем путь, соединяя несколько точек. Мы можем соединить их дугами, кривыми и прямыми линиями. В этом случае у нас очень простой пример. Мы просто начинаем путь (beginPath), двигаемся до координаты (moveTo), затем рисуем прямую линию до следующей координаты (lineTo). Затем мы заканчиваем его командой stroke.

Можно также закончить путь с помощью команды “fill”, но это имеет смысл только с формами.

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

Обработка событий

Теперь, когда мы нарисовали сцену, давайте начнем игру, обрабатывая взаимодействие с пользователями. Обработка событий – самая простая часть игры. Мы слушаем события “mousedown” и “mouseup” и обрабатываем событие “click” кнопки перезапуска.

Когда пользователь нажимает кнопку мыши, мы начинаем фазу растягивания, устанавливая переменную “phase” в значение “stretching”. Мы сбрасываем временную метку, которую будет использовать главный цикл событий (мы вернемся к этому позже), и запускаем главный цикл событий, запросив анимационный кадр для функции “animate”.

Все это происходит только в случае, если текущее состояние игры ожидание. В любом другом случае мы игнорируем событие “mousedown”.

let phase = "waiting";let lastTimestamp;...const restartButton = document.getElementById("restart");...window.addEventListener("mousedown", function () {  if (phase == "waiting") {    phase = "stretching";    lastTimestamp = undefined;    window.requestAnimationFrame(animate);  }});window.addEventListener("mouseup", function () {  if (phase == "stretching") {    phase = "turning";  }});restartButton.addEventListener("click", function (event) {  resetGame();  restartButton.style.display = "none";});...

Обработка события “mouseup” еще проще. Если мы в настоящее время растягиваем палку, то останавливаемся и переходим к следующей фазе, когда палка падает.

Наконец, мы добавляем обработчик событий для кнопки перезапуска. Кнопка сброса имеет по умолчанию скрытое состояние и становится видимой только после падения героя. Но мы уже можем определить ее поведение, и когда она появится, она будет работать. Если мы нажимаем на кнопку сброса, мы вызываем функцию “resetGame” для сброса игры и скрываем кнопку.

Вот все, что мы имеем для обработки событий. Весь остальной код зависит от главного цикла анимации, который мы только что вызвали с помощью “requestAnimationFrame”.

Главный цикл анимации

Главный цикл – самая сложная часть игры. Это функция, которая будет изменять состояние игры и вызывать функцию “draw” для перерисовки всего экрана на основе этого состояния.

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

Эта функция “animate” вызывается как вызов “requestAnimationFrame” через событие “mousedown” (см. выше). В последней строке она продолжает вызывать саму себя, пока мы не остановим ее, вернувшись из функции.

Есть только два случая, когда мы остановим цикл: когда переходим в фазу “waiting” и нет ничего для анимации, или когда герой падает и игра окончена.

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

let lastTimestamp;...function animate(timestamp) {  if (!lastTimestamp) {    // Первый цикл    lastTimestamp = timestamp;    window.requestAnimationFrame(animate);    return;  }  let timePassed = timestamp - lastTimestamp;  switch (phase) {    case "waiting":      return; // Остановить цикл    case "stretching": {      sticks[sticks.length - 1].length += timePassed / stretchingSpeed;      break;    }    case "turning": {      sticks[sticks.length - 1].rotation += timePassed / turningSpeed;      ...      break;    }    case "walking": {      heroX += timePassed / walkingSpeed;      ...      break;    }    case "transitioning": {      sceneOffset += timePassed / transitioningSpeed;      ...      break;    }    case "falling": {      heroY += timePassed / fallingSpeed;      ...      break;    }  }    draw();  lastTimestamp = timestamp;  window.requestAnimationFrame(animate);}

Как рассчитать время, прошедшее между двумя отрисовками

Функции, вызываемые с помощью функции requestAnimationFrame, получают текущий timestamp в качестве атрибута. В конце каждого цикла мы сохраняем это значение timestamp в атрибуте lastTimestamp, чтобы в следующем цикле мы могли рассчитать, сколько времени прошло между двумя циклами. В представленном коде это переменная timePassed.

Первый цикл является исключением, потому что на этой стадии у нас еще нет предыдущего цикла. Изначально значение lastTimestamp равно undefined. В этом случае мы пропускаем отрисовку и рендерим сцену только на втором цикле, где у нас уже есть все значения, которые нам нужны. Это часть в самом начале функции animate.

Как анимировать часть состояния

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

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

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

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

// Конфигурацияconst stretchingSpeed = 4; // Миллисекунды, требующиеся для отрисовки одного пикселяconst turningSpeed = 4; // Миллисекунды, требующиеся для поворота на градусconst walkingSpeed = 4;const transitioningSpeed = 2;const fallingSpeed = 2;...

Как перейти к следующей стадии

В большинстве этих стадий у нас также есть пороговое значение, которое завершает стадию и запускает следующую. Стадии ожидания и растягивания являются исключениями, потому что их завершение зависит от взаимодействия пользователя. |Стадия ожидания заканчивается событием mousedown, а стадия растягивания – событием mouseup.

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

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

Стадия ожидания

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

Stick-Hero.001-1
На стадии ожидания ничего не происходит
function animate(timestamp) {	...  switch (phase) {    case "waiting":      return; // Остановить цикл    ...  }	...}

Стадия растягивания

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

Stick-Hero.002-1
На стадии растягивания мы увеличиваем длину последней палки
function animate(timestamp) {	...  switch (phase) {		...    case "stretching": {      sticks[sticks.length - 1].length += timePassed / stretchingSpeed;			break;    }		...  }  ...}

Фаза поворота

В фазе поворота мы меняем поворот последнего стержня. Мы делаем это только до тех пор, пока стержень не достигнет 90 градусов, потому что это означает, что стержень достиг плоского положения. Затем мы устанавливаем фазу на “ходьбу”, чтобы следующий requestAnimationFrame регулировал героя, а не стержень.

Stick-Hero.003-1
На фазе поворота мы увеличиваем поворот последнего стержня

Когда стержень достигает 90 градусов и если стержень падает на следующую платформу, мы также увеличиваем значение счета. Мы увеличиваем состояние score и обновляем атрибут innerText элемента scoreElement (см. описание главы JavaScript-файла). Затем мы генерируем новую платформу, чтобы быть уверенными, что у нас никогда не закончатся.

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

function animate(timestamp) {  ...  switch (phase) {    ...    case "turning": {      sticks[sticks.length - 1].rotation += timePassed / turningSpeed;      if (sticks[sticks.length - 1].rotation >= 90) {        sticks[sticks.length - 1].rotation = 90;        const nextPlatform = thePlatformTheStickHits();        if (nextPlatform) {          score++;          scoreElement.innerText = score;          generatePlatform();        }        phase = "walking";      }      break;    }    ...  }  ...}

В этой фазе используется вспомогательная функция для определения, упадет ли стержень на платформу или нет. Она вычисляет правую позицию конца последнего стержня и проверяет, попадает ли эта позиция между левым и правым краем платформы. Если да, то возвращается платформа, если нет, то возвращается undefined.

function thePlatformTheStickHits() {  const lastStick = sticks[sticks.length - 1];  const stickFarX = lastStick.x + lastStick.length;  const platformTheStickHits = platforms.find(    (platform) => platform.x < stickFarX && stickFarX < platform.x + platform.w  );  return platformTheStickHits;}

Фаза ходьбы

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

Stick-Hero.004-1
На фазе ходьбы мы увеличиваем позицию X героя

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

function animate(timestamp) {  ...  switch (phase) {    ...    case "walking": {      heroX += timePassed / walkingSpeed;      const nextPlatform = thePlatformTheStickHits();      if (nextPlatform) {        // Если герой достигнет другой платформы, то ограничим его позицию ее краем        const maxHeroX = nextPlatform.x + nextPlatform.w - 30;        if (heroX > maxHeroX) {          heroX = maxHeroX;          phase = "transitioning";        }      } else {        // Если герой не достигнет другой платформы, то ограничим его позицию концом стержня        const maxHeroX =          sticks[sticks.length - 1].x +          sticks[sticks.length - 1].length;        if (heroX > maxHeroX) {          heroX = maxHeroX;          phase = "falling";        }      }      break;    }    ...  }  ...}

Фаза перехода

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

Stick-Hero.005-1
В фазе перехода мы сдвигаем целое представление
function animate(timestamp) {	...  switch (phase) {		...    case "transitioning": {      sceneOffset += timePassed / transitioningSpeed;      const nextPlatform = thePlatformTheStickHits();      if (nextPlatform.x + nextPlatform.w - sceneOffset < 100) {        sticks.push({          x: nextPlatform.x + nextPlatform.w,          length: 0,          rotation: 0,        });        phase = "waiting";      }      break;    }		...  }  ...}

Мы знаем, что мы достигли правильной позиции, когда правая сторона платформы – сдвинутая на смещение – достигает исходной правой позиции первой платформы. Если мы взглянем на инициализацию платформы, мы увидим, что у первой платформы всегда позиция X равна 50, а ее ширина также всегда равна 50. Это означает, что ее правый конец будет находиться на позиции 100.

В конце этой фазы мы также добавили новую палку в массив палок с начальными значениями.

Фаза падения

В сценарии провала две вещи меняются: позиция героя и поворот последней палки. Затем, когда герой выпадает за пределы экрана, мы снова останавливаем игровой цикл, возвращаясь из функции.

Stick-Hero.006
В фазе падения мы увеличиваем позицию Y героя и поворот последней палки
function animate(timestamp) {	...  switch (phase) {		...    case "falling": {      heroY += timePassed / fallingSpeed;      if (sticks[sticks.length - 1].rotation < 180) {        sticks[sticks.length - 1].rotation += timePassed / turningSpeed;      }      const maxHeroY = platformHeight + 100;      if (heroY > maxHeroY) {        restartButton.style.display = "block";        return;      }      break;    }		...  }  ...}

Вот и основной цикл – как игра переходит от фазы к фазе, изменяя ряд переменных. В конце каждого цикла функция вызывает функцию draw, чтобы обновить сцену и запросить следующий кадр. Если вы все делали правильно, теперь у вас должна быть работающая игра!

Резюме

В этом учебном пособии мы рассмотрели многое. Мы узнали, как рисовать базовые фигуры на элементе canvas с помощью JavaScript, и реализовали целую игру.

Несмотря на длину этой статьи, здесь остались еще несколько вещей, которые мы не рассмотрели. Вы можете посмотреть исходный код этой игры для дополнительных функций на CodePen. Среди них:

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

Надеюсь, вам понравилось это учебное пособие. Оставайтесь с нами на CodesCode и на моем YouTube-канале для получения дальнейших новостей.

Подпишитесь на больше уроков по Веб-разработке:

Hunor Márton BorbélyИгры на JavaScript, уроки по креативному кодированию, HTML-канвас, SVG, Three.js, а также немного React и Vue https://twitter.com/HunorBorbelyhttps://codepen.io/HunorMarton…favicon_144x144YouTubeAPkrFKaQ34YAITK6J0qgy6Iv6pms35dPhF68Hyy7BoYoLA=s900-c-k-c0x00ffffff-no-rj

Leave a Reply

Your email address will not be published. Required fields are marked *