Разрабатываем игру на Svelte 3

Чуть больше месяца назад вышел релиз Svelte 3. Хороший момент для знакомства, — подумал я и пробежался по отличному туториалу, который еще и переведен на русский.

Для закрепления пройденного я сделал небольшой проект и делюсь результатами с вами. Это не one-more-todo-list, а игра, в которой нужно отстреливаться от черных квадратов.
image


0. Для нетерпеливых

Репозиторий туториала
Репозиторий с дополнениями
Демо


1. Подготовка

Клонируем шаблон для разработки

git clone https://github.com/sveltejs/template.git

Устанавливаем зависимости.

cd template/
npm i

Запускаем dev сервер.

npm run dev

Наш шаблон доступен по адресу
http://localhost:5000. Сервер поддерживает hot reload, поэтому наши изменения будут видны в браузере по мере сохранения изменений.

Если вы не хотите разворачивать среду локально, то можете использовать онлайн песочницы codesandbox и stackblitz, которые поддерживают Svelte.


2. Каркас игры

Папка src состоит из двух файлов main.js и App.svelte.
main.js — это точка входа в наше приложение. Во время разработки мы ее трогать не будем. Здесь компонент App.svelte монтируется в body документа.
App.svelte — это компонент svelte. Шаблон компонента состоит из трех частей:





Hello {name}!

Стили компонента изолированы, но есть возможность назначить глобальные стили директивой : global (). Подробнее о стилях.
Добавим общие стили для нашего компонента


src/App.svelte




Hello {name}!

Давайте создадим папку src/components, в которой будут храниться наши компоненты
В этой папке создадим два файла, которые будут содержать игровое поле и элементы управления.


src/components/GameField.svelte
GameField


src/components/Controls.svelte
Controls

Импорт компонента осуществляется директивой

import Controls from "./components/Controls.svelte";

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

Теперь импортируем и отобразим наши компоненты в App.svelte.


src/App.svelte







3. Элементы управления

Компонент Controls.svelte будет состоять из трех кнопок: движение влево, движение вправо, огонь. Иконки кнопок будут отображаться svg элементом.
Создадим папку src/asssets, в которую добавим наши svg иконки.


src/assets/Bullet.svelte

  
  
  
  


src/assets/LeftArrow.svelte

  


src/assets/RightArrow.svelte

  
    
  

Добавим компонент кнопки src/components/IconButton.svelte.
Мы будем принимать обработчики событий из родительского компонента. Для того, чтобы можно было зажать кнопку, нам понадобятся два обработчика: начало нажатия и конец нажатия. Объявим переменные start и release, куда будем принимать обработчики событий начала и окончания нажатия. Еще нам понадобится переменная active, которая будет отображать, нажата кнопка или нет.

Стилизуем наш компонент

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

Обработчики событий обозначаются через директиву on: , например, on: click.
Мы будем обрабатывать события мыши и тач нажатия. Подробнее о привязке событий.
К базовому классу компонента будет добавляться класс active, если кнопка нажата. Назначить класс можно свойством class. Подробнее о классах

В итоге наш компонент будет выглядеть следующим образом:


src/components/IconButton.svelte




Теперь импортируем наши иконки и элемент кнопки в src/components/Controls.svelte и сверстаем расположение.


src/components/Controls.svelte




Наше приложение должно выглядеть так:
image


4. Игровое поле

Игровое поле представляет собой svg компонент, куда мы будем добавлять наши элементы игры (пушку, снаряды, противников).
Обновим код src/components/GameField.svelte


src/components/GameField.svelte


Создадим пушку src/components/Cannon.svelte. Громко сказано для прямоугольника, но тем не менее.


src/components/Cannon.svelte




  

Теперь импортируем нашу пушку на игровое поле.


src/GameField.svelte





5. Игровой цикл

У нас есть базовый каркас игры. Следующий шаг — создать игровой цикл, который будет обрабатывать нашу логику.
Создадим хранилища, где будут содержаться переменные для нашей логики. Нам понадобится компонент writable из модуля svelte/store. Подробнее о store.
Создание простого хранилища выглядит так:

// импортируем модуль изменяемой переменной
import { writable } from "svelte/store";

// Объявляем переменную с начальным значением null
export const isPlaying = writable(null);

Создадим папку src/stores/, здесь будут храниться все изменяемые значения нашей игры.
Создадим файл src/stores/game.js, в котором будут храниться переменные, отвечающие за общее состояние игры.


src/stores/game.js
// импортируем модуль изменяемой переменной
import { writable } from "svelte/store";

// Запущен в данный момент игровой цикл или нет, может принимать значения true/false
export const isPlaying = writable(false);

Создадим файл src/stores/cannon.js, в котором будут храниться переменные, отвечающие за состояние пушки


src/stores/cannon.js
// импортируем модуль изменяемой переменной
import { writable } from "svelte/store";

// Отвечает за текущее направление, в котором нужно поворачивать пушку. 
// Будет принимать значения 'left', 'right', null, устанавливается нашими кнопками
export const direction = writable(null);

// Текущий угол поворота пушки
export const angle = writable(0);

Svelte позволяет создавать пользовательские хранилища, включающие логику работы. Подробнее об этом можно почитать в учебнике. У меня не получилось красиво вписать это в концепцию игрового цикла, поэтому в хранилище мы только объявляем переменные. Все манипуляции с ними мы будем производить в разделе src/gameLoop.

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

Используя хранилище можно создавать подписку на значение. Этот функционал мы будем использовать в компонентах. Пока для чтения значения переменной будем использовать функцию get. Для установки значения будем использовать метод .set () переменной.
Обновить значение можно вызвав метод .update (), который на вход принимает функцию, в первый аргумент которого передается текущее значение. Подробнее в документации. Все остальное — чистый JS.


src/gameLoop/gameLoop.js
// Импортируем переменную из хранилища
import { isPlaying } from '../stores/game';
// с помощью функции get можно получить текущее значение стора, без подписки.
import { get } from 'svelte/store';

// Функция отвечает за игровой цикл
function startLoop(steps) {
  window.requestAnimationFrame(() => {
    // Проходим по массиву игровых шагов
    steps.forEach(step => {
      // Если шаг функция - запускаем
      if (typeof step === 'function') step();
    });
    // Если игра не остановилась, планируем следующий цикл
    if (get(isPlaying)) startLoop(steps);
  });
}

// Функция отвечает за запуск игрового цикла
export const startGame = () => {
  // Устанавливаем переменную, которая хранит состояние игры в true
  isPlaying.set(true);
  // запускаем игровой цикл. Пока массив шагов пустой
  startLoop([]);
};

// Функция отвечает за остановку игрового цикла
export function stopGame() {
  // Устанавливаем переменную, которая хранит состояние игры в false
  isPlaying.set(false);
}

Теперь опишем логику поведения нашей пушки.


src/gameLoop/cannon.js
// с помощью функции get можно получить текущее значение стора, без подписки.
import { get } from 'svelte/store';

// Импорт всех переменных из хранилища cannon
import { angle, direction } from '../stores/cannon.js';

// Функция обновления угла поворота пушки
export function rotateCannon() {
  // Получаем текущий угол поворота
  const currentAngle = get(angle);
  // В зависимости от того, какая кнопка зажата, обновляем угол поворота
  switch (get(direction)) {
    // Если зажата кнопка "влево" и угол поворота меньше -45°, 
    // то уменьшаем угол поворота на 0.4
    case 'left':
      if (currentAngle > -45) angle.update(a => a - 0.4);
      break;
    // Если зажата кнопка "вправо" и угол поворота меньше 45°,
    // то увеличиваем угол поворота на 0.4
    case 'right':
      if (currentAngle < 45) angle.update(a => a + 0.4);
      break;
    default:
      break;
  }
}

Теперь добавим наш обработчик поворота пушки в игровой цикл.

import { rotateCannon } from "./cannon";
/* ... */
export const startGame = () => {
  isPlaying.set(true);
  startLoop([rotateCannon]);
};

Текущий код игрового цикла:


src/gameLoop/gameLoop.js
import { isPlaying } from '../stores/game';
import { get } from 'svelte/store';

import { rotateCannon } from './cannon'; // импортируем обработчик поворота пушки

function startLoop(steps) {
  window.requestAnimationFrame(() => {
    steps.forEach(step => {
      if (typeof step === 'function') step();
    });
    if (get(isPlaying)) startLoop(steps);
  });
}

export const startGame = () => {
  isPlaying.set(true);
  startLoop([rotateCannon]); // Добавим обработчик в игровой цикл
};

export function stopGame() {
  isPlaying.set(false);
}

У нас есть логика, которая умеет поворачивать пушку. Но мы еще не связали ее с нажатием кнопок. Самое время сделать это. Обработчики событий нажатий будем добавлять в src/components/Controls.svelte.

import { direction } from "../stores/cannon.js"; // импортируем переменную направления поворота из хранилища

// создаем обработчики событий 
const resetDirection = () => direction.set(null);
const setDirectionLeft = () => direction.set("left");
const setDirectionRight = () => direction.set("right");

Добавим наши обработчики и текущее состояние нажатия в элементы IconButton. Для этого просто передадим значения в ранее созданные атрибуты start, release и active, как описано в документации.


  


  

Мы использовали выражение $ для переменной $direction. Этот синтаксис делает значение реактивным, автоматически создавая подписку на изменения. Подробнее в документации.


src/components/Controls.svelte




На данный момент при нажатии у нашей кнопки происходит выделение, но пушка еще не поворачивается. Нам необходимо импортировать значение angle в компонент Cannon.svelte и обновить правила трансформации transform


src/components/Cannon.svelte






  

Осталось запустить наш игровой цикл в компоненте App.svelte.

import { startGame } from "./gameLoop/gameLoop";
startGame();


App.svelte





Ура! Наша пушка начала двигаться.
image


6. Выстрелы

Теперь научим нашу пушку стрелять. Нам нужно хранить значения:


  • Стреляет ли сейчас пушка (зажата кнопка огонь);
  • Временную метку последнего выстрела, нужно для расчета скорострельности;
  • Массив снарядов.

Добавим эти переменные в наше хранилище src/stores/cannon.js.


src/stores/cannon.js
import { writable } from 'svelte/store';

export const direction = writable(null);
export const angle = writable(0);
// Добавляем переменные
export const isFiring = writable(false);
export const lastFireAt = writable(0);
export const bulletList = writable([]);

Обновим импорты и игровую логику в src/gameLoop/cannon.js.


src/gameLoop/cannon.js
import { get } from 'svelte/store';
// Обновим импорты
import { angle, direction, isFiring, lastFireAt, bulletList } from '../stores/cannon.js';

export function rotateCannon() {
  const currentAngle = get(angle);
  switch (get(direction)) {
    case 'left':
      if (currentAngle > -45) angle.update(a => a - 0.4);
      break;
    case 'right':
      if (currentAngle < 45) angle.update(a => a + 0.4);
      break;
    default:
      break;
  }
}

// Функция выстрела
export function shoot() {
  // Если зажата кнопка огня и последний выстрел произошел более чем 800мс назад, 
  // то добавляем снаряд в массив и обновляем временную метку
  if (get(isFiring) && Date.now() - get(lastFireAt) > 800) {
    lastFireAt.set(Date.now());
    // Позиция и угол поворота снаряда совпадают с положением пушки. 
    // Для id используем функцию Math.random и временную метку
    bulletList.update(bullets => [...bullets, { x: 238, y: 760, angle: get(angle), id: () => Math.random() + Date.now() }]);
  }
}

// Функция перемещения снарядов
export function moveBullet() {
  // Возвращаем новый массив снарядов, в котором сдвигаем положение оси y на -20, 
  // а положение по оси х рассчитываем по формуле прямоугольного треугольника. 
  // Для знатоков геометрии отвечу, да, по диагонали снаряд летит быстрее. 
  // Но визуально вы этого не заметили, верно?
  bulletList.update(bullets =>
    bullets.map(bullet => ({
      ...bullet,
      y: bullet.y - 20,
      x: (780 - bullet.y) * Math.tan((bullet.angle * Math.PI) / 180) + 238,
    })),
  );
}

// Удаляем снаряд из массива, если он вылетел за экран.
export function clearBullets() {
  bulletList.update(bullets => bullets.filter(bullet => bullet.y > 0));
}

// Функция удаления снаряда по Id. Пригодится, когда мы добавим противников и обработку столкновений
export function removeBullet(id) {
  bulletList.update(bullets => bullets.filter(bullet => bullet.id !== id));
}

Теперь импортируем наши обработчики в gameLoop.js и добавим их в игровой цикл.

import { rotateCannon, shoot, moveBullet, clearBullets } from "./cannon";
/* ... */
export const startGame = () => {
  isPlaying.set(true);
  startLoop([rotateCannon, shoot, moveBullet, clearBullets ]); 
};


src/gameLoop/gameLoop.js
import { isPlaying } from '../stores/game';
import { get } from 'svelte/store';
// Импортируем все обработчики событий пушки и снарядов
import { rotateCannon, shoot, moveBullet, clearBullets } from "./cannon";

function startLoop(steps) {
  window.requestAnimationFrame(() => {
    steps.forEach(step => {
      if (typeof step === 'function') step();
    });
    if (get(isPlaying)) startLoop(steps);
  });
}

export const startGame = () => {
  isPlaying.set(true);
  // добавим обработчики в игровой цикл
  startLoop([rotateCannon, shoot, moveBullet, clearBullets ]); 
};

export function stopGame() {
  isPlaying.set(false);
}

Теперь нам осталось создать обработку нажатия кнопки огонь и добавить отображение снарядов на игровом поле.
Отредактируем src/components/Controls.svelte.

// Импортируем переменную, которая отвечает за нажатие кнопки огонь
import { direction, isFiring } from "../stores/cannon.js";
// Добавим обработчики нажатия кнопки огонь
const startFire = () => isFiring.set(true);
const stopFire = () => isFiring.set(false);

Теперь добавим наши обработчики к кнопке, управляющей огнем, как мы делали это с кнопками поворота


  


src/components/Controls.svelte




Осталось отобразить снаряды на игровом поле. Сначала создадим компонент снаряда


src/components/Bullet.svelte



  

Поскольку снаряды у нас хранятся в массиве, нам понадобится итератор для их отображения. В svelte для таких случаев есть директива Each. Подробнее в документации.

// Проходим по массиву bulletList, записывая каждый объект в переменную bullet.
// Выражение в скобках указывает на id каждого объекта, так svelte может оптимизировать вычисления и обновлять только то, что действительно обновилось. 
// Аналог key из мира React
{#each $bulletList as bullet (bullet.id)}
  
{/each}


src/components/GameField.svelte




{#each $bulletList as bullet (bullet.id)} {/each}

Теперь наша пушка умеет стрелять.
image


7. Враги

Отлично. Для минимального геймплея нам осталось добавить врагов. Давайте создадим хранилище src/stores/enemy.js.


src/stores/enemy.js
import { writable } from "svelte/store";

// Массив врагов
export const enemyList = writable([]);
// Временная метка добавления последнего врага
export const lastEnemyAddedAt = writable(0);

Создадим обработчики игрового цикла для врагов в src/gameLoop/enemy.js


src/gameLoop/enemy.js
import { get } from 'svelte/store';
// Импортируем переменные врагов из хранилища
import { enemyList, lastEnemyAddedAt } from '../stores/enemy.js';

// Функция добавления врага
export function addEnemy() {
  // Если с момента добавления последнего врага прошло больше 2500 мс, 
  // то добавить нового врага
  if (Date.now() - get(lastEnemyAddedAt) > 2500) {
    // Обновим временную метку последнего добавления
    lastEnemyAddedAt.set(Date.now());
    // Добавим врага со случайной координатой х от 1 до 499 
    // (размер нашего игрового поля)
    enemyList.update(enemies => [
      ...enemies,
      {
        x: Math.floor(Math.random() * 449) + 1,
        y: 0,
        id: () => Math.random() + Date.now(),
      },
    ]);
  }
}

// Функция перемещения врага. Каждый игровой цикл перемещаем врага на 0.5
export function moveEnemy() {
  enemyList.update(enemyList =>
    enemyList.map(enemy => ({
      ...enemy,
      y: enemy.y + 0.5,
    })),
  );
}

// Удалить врага из массива по id, пригодится для обработки попаданий
export function removeEnemy(id) {
  enemyList.update(enemies => enemies.filter(enemy => enemy.id !== id));
}

Добавим обработчики врагов в наш игровой цикл.


src/gameLoop/gameLoop.js
import { isPlaying } from '../stores/game';
import { get } from 'svelte/store';
import { rotateCannon, shoot, moveBullet, clearBullets } from './cannon';
// Импортируем все обработчики событий врагов
import { addEnemy, moveEnemy } from './enemy';

function startLoop(steps) {
  window.requestAnimationFrame(() => {
    steps.forEach(step => {
      if (typeof step === 'function') step();
    });
    if (get(isPlaying)) startLoop(steps);
  });
}

export const startGame = () => {
  isPlaying.set(true);
  // добавим обработчики в игровой цикл
  startLoop([rotateCannon, shoot, moveBullet, clearBullets, addEnemy, moveEnemy]);
};

export function stopGame() {
  isPlaying.set(false);
}

Создадим компонент src/components/Enemy.js по аналогии со снарядом.


src/components/Enemy.js


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

    

Осталось импортировать компонент врага, массив с объектами врагов в наше игровое поле и отобразить их в цикле Each


src/components/GameField.svelte




{#each $enemyList as enemy (enemy.id)} {/each} {#each $bulletList as bullet (bullet.id)} {/each}

Враг наступает!
image


8. Столкновения

Пока наши снаряды пролетают мимо, не причинив никакого вреда врагам.
Самое время добавить обработку столкновений. Общая игровая логика будет жить в файле src/gameLoop/game.js. Описание методики расчета столкновений можно прочитать на MDN


src/gameLoop/game.js
import { get } from 'svelte/store';
// Импортируем массив снарядов
import { bulletList } from '../stores/cannon';
// Импортируем массив врагов
import { enemyList } from '../stores/enemy';
// Импортируем обработчик удаления снарядов
import { removeBullet } from './cannon';
// Импортируем обработчик удаления врагов
import { removeEnemy } from './enemy';

// Запишем в константы размеры врагов и снарядов. 
// Размер снаряда сделан чуть больше, чем наш svg, чтобы компенсировать расстояние, 
// которое пройдет снаряд и враг за игровой цикл. 
const enemyWidth = 30;
const bulletWidth = 5;
const enemyHeight = 30;
const bulletHeight = 8;

// Функция обработки столкновений
export function checkCollision() {
  get(bulletList).forEach(bullet => {
    get(enemyList).forEach(enemy => {
      if (
        bullet.x < enemy.x + enemyWidth &&
        bullet.x + bulletWidth > enemy.x &&
        bullet.y < enemy.y + enemyHeight &&
        bullet.y + bulletHeight > enemy.y
      ) {
          // Если произошло столкновение, то удаляем снаряд и врага с игрового поля
        removeBullet(bullet.id);
        removeEnemy(enemy.id);
      }
    });
  });
}

Осталось добавить обработчик столкновений в игровой цикл.


src/gameLoop/gameLoop.js
import { isPlaying } from '../stores/game';
import { get } from 'svelte/store';
import { rotateCannon, shoot, moveBullet, clearBullets } from './cannon';
// импортируем обработчик столкновений
import { checkCollision } from './game';
import { addEnemy, moveEnemy } from './enemy';

function startLoop(steps) {
  window.requestAnimationFrame(() => {
    steps.forEach(step => {
      if (typeof step === 'function') step();
    });
    if (get(isPlaying)) startLoop(steps);
  });
}

export const startGame = () => {
  isPlaying.set(true);
  // добавим обработчик в игровой цикл
  startLoop([rotateCannon, shoot, moveBullet, clearBullets, addEnemy, moveEnemy, checkCollision]);
};

export function stopGame() {
  isPlaying.set(false);
}

Отлично, наши снаряды научились поражать цель.
image


9. Что дальше

Если вы дожили до этого момента и не потеряли интерес к нашим квадратным войнам, то у меня есть список ToDo на самостоятельное изучение:


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

Мою реализацию этого списка вы можете посмотреть на github и в демо.


Заключение

Эту игру, в качестве обучающего примера, я пытался сделать на React. Из коробки мне не удалось завести игру в 60 FPS, а вот со Svelte получилось с первой попытки.
Попробуйте Svelte прямо сейчас, вам понравится.

© Habrahabr.ru