Темы



Разрабатываем игру на 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. Шаблон компонента состоит из трех частей:

<script>
    // JS код компонента
    export let name;
</script>



Hello {name}!

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


src/App.svelte
<script>
  export let name;
</script>



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
<script>
  // импортируем компоненты
  import Controls from "./components/Controls.svelte";
  import GameField from "./components/GameField.svelte";
</script>






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, которая будет отображать, нажата кнопка или нет.

<script>
  export let start;
  export let release;
  export let active;
</script>

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

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

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

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


src/components/IconButton.svelte
<script>
  export let start;
  export let release;
  export let active;
</script>



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


src/components/Controls.svelte
<script>
  // импортируем компонент кнопки и иконки
  import IconButton from "./IconButton.svelte";
  import LeftArrow from "../assets/LeftArrow.svelte";
  import RightArrow from "../assets/RightArrow.svelte";
  import Bullet from "../assets/Bullet.svelte";
</script>



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


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

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


src/components/GameField.svelte


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


src/components/Cannon.svelte




  

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


src/GameField.svelte
<script>
  // Импортируем компонент пушки
  import Cannon from "./Cannon.svelte";
</script>




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
<script>
  import IconButton from "./IconButton.svelte";
  import LeftArrow from "../assets/LeftArrow.svelte";
  import RightArrow from "../assets/RightArrow.svelte";
  import Bullet from "../assets/Bullet.svelte";
  // импортируем переменную направления поворота
  import { direction } from "../stores/cannon.js"; 

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



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


src/components/Cannon.svelte
<script>
  // Импортируем угол поворота из хранилища
  import { angle } from "../stores/cannon.js";
</script>





  

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

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


App.svelte
<script>
  import Controls from "./components/Controls.svelte";
  import GameField from "./components/GameField.svelte";
  // импортируем функцию страта игры
  import { startGame } from "./gameLoop/gameLoop";
  // Запускаем
  startGame();
</script>




Ура! Наша пушка начала двигаться.
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
<script>
  import IconButton from "./IconButton.svelte";
  import LeftArrow from "../assets/LeftArrow.svelte";
  import RightArrow from "../assets/RightArrow.svelte";
  import Bullet from "../assets/Bullet.svelte";
  // Импортируем переменную, которая отвечает за нажатие кнопки огонь
  import { direction, isFiring } from "../stores/cannon.js";

  const resetDirection = () => direction.set(null);
  const setDirectionLeft = () => direction.set("left");
  const setDirectionRight = () => direction.set("right");
  // Добавим обработчики нажатия кнопки огонь
  const startFire = () => isFiring.set(true);
  const stopFire = () => isFiring.set(false);
</script>



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


src/components/Bullet.svelte
<script>
 // В переменную bullet принимаем объект, описывающий положение снаряда
  export let bullet;
</script>


  

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

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


src/components/GameField.svelte
<script>
  import Cannon from "./Cannon.svelte";
  // Импортируем компонент снаряда
  import Bullet from "./Bullet.svelte";
  // импортируем список снарядов из хранилища
  import { bulletList } from "../stores/cannon";
</script>



{#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
<script>
 // В переменную enemy будем принимать объект, описывающий врага
 export let enemy;
</script>

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

    

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


src/components/GameField.svelte
<script>
  import Cannon from "./Cannon.svelte";
  import Bullet from "./Bullet.svelte";
  // импортируем компонент врагов
  import Enemy from "./Enemy.svelte";

  import { bulletList } from "../stores/cannon";
  // импортируем список врагов из хранилища
  import { enemyList } from "../stores/enemy";
</script>



{#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