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

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> <style> /* CSS стили компонента */ h1 { color: purple; } </style> <!-- разметка компонента --> <h1>Hello {name}!</h1>
Стили компонента изолированы, но есть возможность назначить глобальные стили директивой :global(). Подробнее о стилях.
Добавим общие стили для нашего компонента
<script> export let name; </script> <style> :global(html) { height: 100%; /* Наша игра будет занимать 100% высоты*/ } :global(body) { height: 100%; /* Наша игра будет занимать 100% высоты*/ overscroll-behavior: none; /* отключает pull to refresh*/ user-select: none; /* для тач интерфейсов отключает выделение при нажатии */ margin: 0; /* убираем отступы*/ background-color: #efefef; /* устанавливаем цвет фона */ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; /* устанавливаем шрифты */ } </style> <h1>Hello {name}!</h1>
Давайте создадим папку src/components, в которой будут храниться наши компоненты
В этой папке создадим два файла, которые будут содержать игровое поле и элементы управления.
<div>GameField</div>
<div>Controls</div>
Импорт компонента осуществляется директивой
import Controls from "./components/Controls.svelte";
Для отображения компонента достаточно вставить тег компонента в разметку. Подробнее о тегах.
<Controls />
Теперь импортируем и отобразим наши компоненты в App.svelte.
<script> // импортируем компоненты import Controls from "./components/Controls.svelte"; import GameField from "./components/GameField.svelte"; </script> <style> :global(html) { height: 100%; } :global(body) { height: 100%; overscroll-behavior: none; user-select: none; margin: 0; background-color: #efefef; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; } </style> <!-- Отображаем компоненты. Заметьте, нам не нужен рут компонент, как, например, в react --> <Controls /> <GameField />
3. Элементы управления
Компонент Controls.svelte будет состоять из трех кнопок: движение влево, движение вправо, огонь. Иконки кнопок будут отображаться svg элементом.
Создадим папку src/asssets, в которую добавим наши svg иконки.
<svg height="40px" viewBox="0 0 427 427.08344" width="40px"> <path d="m341.652344 38.511719-37.839844 37.839843 46.960938 46.960938 37.839843-37.839844c8.503907-8.527344 15-18.839844 19.019531-30.191406l19.492188-55.28125-55.28125 19.492188c-11.351562 4.019531-21.664062 10.515624-30.191406 19.019531zm0 0" /> <path d="m258.65625 99.078125 69.390625 69.390625 14.425781-33.65625-50.160156-50.160156zm0 0" /> <path d="m.0429688 352.972656 28.2812502-28.285156 74.113281 74.113281-28.28125 28.28125zm0 0" /> <path d="m38.226562 314.789062 208.167969-208.171874 74.113281 74.113281-208.171874 208.171875zm0 0" /> </svg>
<svg width="40px" height="40px" viewBox="0 0 292.359 292.359" transform="translate(-5 0)"> <path d="M222.979,5.424C219.364,1.807,215.08,0,210.132,0c-4.949,0-9.233,1.807-12.848,5.424L69.378,133.331 c-3.615,3.617-5.424,7.898-5.424,12.847c0,4.949,1.809,9.233,5.424,12.847l127.906,127.907c3.614,3.617,7.898,5.428,12.848,5.428 c4.948,0,9.232-1.811,12.847-5.428c3.617-3.614,5.427-7.898,5.427-12.847V18.271C228.405,13.322,226.596,9.042,222.979,5.424z" /> </svg>
<svg width="40px" height="40px" viewBox="0 0 292.359 292.359" transform="translate(5 0) rotate(180)"> <path d="M222.979,5.424C219.364,1.807,215.08,0,210.132,0c-4.949,0-9.233,1.807-12.848,5.424L69.378,133.331 c-3.615,3.617-5.424,7.898-5.424,12.847c0,4.949,1.809,9.233,5.424,12.847l127.906,127.907c3.614,3.617,7.898,5.428,12.848,5.428 c4.948,0,9.232-1.811,12.847-5.428c3.617-3.614,5.427-7.898,5.427-12.847V18.271C228.405,13.322,226.596,9.042,222.979,5.424z" /> </svg>
Добавим компонент кнопки src/components/IconButton.svelte.
Мы будем принимать обработчики событий из родительского компонента. Для того, чтобы можно было зажать кнопку, нам понадобятся два обработчика: начало нажатия и конец нажатия. Объявим переменные start и release, куда будем принимать обработчики событий начала и окончания нажатия. Еще нам понадобится переменная active, которая будет отображать, нажата кнопка или нет.
<script> export let start; export let release; export let active; </script>
Стилизуем наш компонент
<style> .iconButton { /* С помощью flex выравниваем содержимое по центру */ display: flex; align-items: center; justify-content: center; /* Устанавливаем размер элемента 60px */ width: 60px; height: 60px; /* Добавляем обводку */ border: 1px solid black; /* Делаем обводку круглой */ border-radius: 50px; /* Убираем лишние стили кнопки */ outline: none; background: transparent; } .active { /* Устанавливаем фон для состояния, когда кнопка нажата */ background-color: #bdbdbd; } </style>
Кнопка представляет собой button элемент, внутри которого отображается контент, переданный из родительского компонента. Место, где будут монтироваться переданный контент обозначается тегом <slot/>. Подробнее об элементе <slot/>.
<button> <slot /> </button>
Обработчики событий обозначаются через директиву on:, например, on:click.
Мы будем обрабатывать события мыши и тач нажатия. Подробнее о привязке событий.
К базовому классу компонента будет добавляться класс active, если кнопка нажата. Назначить класс можно свойством class. Подробнее о классах
<button on:mousedown={start} on:touchstart={start} on:mouseup={release} on:touchend={release} class={`iconButton ${active ? 'active' : ''}`}> <slot /> </button>
В итоге наш компонент будет выглядеть следующим образом:
<script> export let start; export let release; export let active; </script> <style> .iconButton { display: flex; align-items: center; justify-content: center; width: 60px; height: 60px; border: 1px solid black; border-radius: 50px; outline: none; background: transparent; } .active { background-color: #bdbdbd; } </style> <button on:mousedown={start} on:touchstart={start} on:mouseup={release} on:touchend={release} class={`iconButton ${active ? 'active' : ''}`}> <slot /> </button>
Теперь импортируем наши иконки и элемент кнопки в 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> <style> /* положение элементов управления фиксированное, внизу экрана */ .controls { position: fixed; bottom: 0; left: 0; width: 100%; } /* контейнер кнопок будет разносить наши элементы по краям экрана */ .container { display: flex; justify-content: space-between; margin: 1rem; } /* сделаем отступ между стрелок */ .arrowGroup { display: flex; justify-content: space-between; width: 150px; } </style> <div class="controls"> <div class="container"> <div class="arrowGroup"> <IconButton> <LeftArrow /> </IconButton> <IconButton> <RightArrow /> </IconButton> </div> <IconButton> <Bullet /> </IconButton> </div> </div>
Наше приложение должно выглядеть так:

4. Игровое поле
Игровое поле представляет собой svg компонент, куда мы будем добавлять наши элементы игры (пушку, снаряды, противников).
Обновим код src/components/GameField.svelte
<style> /* Сделаем так, чтобы наше игровое поле растягивалось на весь экран */ .container { flex-grow: 1; display: flex; flex-direction: column; justify-content: flex-start; max-height: 100%; } </style> <div class="container"> <!-- Благодаря указанию атрибута viewBox пропорции нашего игрового поля будут сохраняться при изменении размеров --> <svg viewBox="0 0 480 800"> </svg> </div>
Создадим пушку src/components/Cannon.svelte. Громко сказано для прямоугольника, но тем не менее.
<style> /* Сместим центр трансформации, чтобы наша пушка вращалась вокруг нижней грани */ .cannon { transform-origin: 4px 55px; } </style> <!-- Наша пушка всего лишь прямоугольник svg элемента. Обертка элементом <g> нужна для корректной трансформации --> <g class="cannon" transform={`translate(236, 700)`}> <rect width="8" height="60" fill="#212121" /> </g>
Теперь импортируем нашу пушку на игровое поле.
<script> // Импортируем компонент пушки import Cannon from "./Cannon.svelte"; </script> <style> .container { flex-grow: 1; display: flex; flex-direction: column; justify-content: flex-start; max-height: 100%; } </style> <div class="container"> <svg viewBox="0 0 480 800"> <!-- Отображаем компонент пушки --> <Cannon /> </svg> </div>
5. Игровой цикл
У нас есть базовый каркас игры. Следующий шаг — создать игровой цикл, который будет обрабатывать нашу логику.
Создадим хранилища, где будут содержаться переменные для нашей логики. Нам понадобится компонент writable из модуля svelte/store. Подробнее о store.
Создание простого хранилища выглядит так:
// импортируем модуль изменяемой переменной import { writable } from "svelte/store"; // Объявляем переменную с начальным значением null export const isPlaying = writable(null);
Создадим папку src/stores/, здесь будут храниться все изменяемые значения нашей игры.
Создадим файл src/stores/game.js, в котором будут храниться переменные, отвечающие за общее состояние игры.
// импортируем модуль изменяемой переменной import { writable } from "svelte/store"; // Запущен в данный момент игровой цикл или нет, может принимать значения true/false export const isPlaying = writable(false);
Создадим файл 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.
// Импортируем переменную из хранилища 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); }
Теперь опишем логику поведения нашей пушки.
// с помощью функции 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]); };
Текущий код игрового цикла:
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, как описано в документации.
<IconButton start={setDirectionLeft} release={resetDirection} active={$direction === 'left'}> <LeftArrow /> </IconButton> <IconButton start={setDirectionRight} release={resetDirection} active={$direction === 'right'}> <RightArrow /> </IconButton>
Мы использовали выражение $ для переменной $direction. Этот синтаксис делает значение реактивным, автоматически создавая подписку на изменения. Подробнее в документации.
<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> <style> .controls { position: fixed; bottom: 0; left: 0; width: 100%; } .container { display: flex; justify-content: space-between; margin: 1rem; } .arrowGroup { display: flex; justify-content: space-between; width: 150px; } </style> <div class="controls"> <div class="container"> <div class="arrowGroup"> <!-- Передаем наши обработчики и направление в атрибуты --> <IconButton start={setDirectionLeft} release={resetDirection} active={$direction === 'left'}> <LeftArrow /> </IconButton> <IconButton start={setDirectionRight} release={resetDirection} active={$direction === 'right'}> <RightArrow /> </IconButton> </div> <IconButton> <Bullet /> </IconButton> </div> </div>
На данный момент при нажатии у нашей кнопки происходит выделение, но пушка еще не поворачивается. Нам необходимо импортировать значение angle в компонент Cannon.svelte и обновить правила трансформации transform
<script> // Импортируем угол поворота из хранилища import { angle } from "../stores/cannon.js"; </script> <style> .cannon { transform-origin: 4px 55px; } </style> <!-- Поворачиваем пушку директивой rotate(${$angle})--> <g class="cannon" transform={`translate(236, 700) rotate(${$angle})`}> <rect width="8" height="60" fill="#212121" /> </g>
Осталось запустить наш игровой цикл в компоненте App.svelte.
import { startGame } from "./gameLoop/gameLoop"; startGame();
<script> import Controls from "./components/Controls.svelte"; import GameField from "./components/GameField.svelte"; // импортируем функцию страта игры import { startGame } from "./gameLoop/gameLoop"; // Запускаем startGame(); </script> <style> :global(html) { height: 100%; } :global(body) { height: 100%; overscroll-behavior: none; user-select: none; margin: 0; background-color: #efefef; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; } </style> <Controls /> <GameField />
Ура! Наша пушка начала двигаться.

6. Выстрелы
Теперь научим нашу пушку стрелять. Нам нужно хранить значения:
- Стреляет ли сейчас пушка (зажата кнопка огонь);
- Временную метку последнего выстрела, нужно для расчета скорострельности;
- Массив снарядов.
Добавим эти переменные в наше хранилище 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.
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 ]); };
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);
Теперь добавим наши обработчики к кнопке, управляющей огнем, как мы делали это с кнопками поворота
<IconButton start={startFire} release={stopFire} active={$isFiring}> <Bullet /> </IconButton>
<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> <style> .controls { position: fixed; bottom: 0; left: 0; width: 100%; } .container { display: flex; justify-content: space-between; margin: 1rem; } .arrowGroup { display: flex; justify-content: space-between; width: 150px; } </style> <div class="controls"> <div class="container"> <div class="arrowGroup"> <IconButton start={setDirectionLeft} release={resetDirection} active={$direction === 'left'}> <LeftArrow /> </IconButton> <IconButton start={setDirectionRight} release={resetDirection} active={$direction === 'right'}> <RightArrow /> </IconButton> </div> <!-- Добавим обработчики для кнопки --> <IconButton start={startFire} release={stopFire} active={$isFiring}> <Bullet /> </IconButton> </div> </div>
Осталось отобразить снаряды на игровом поле. Сначала создадим компонент снаряда
<script> // В переменную bullet принимаем объект, описывающий положение снаряда export let bullet; </script> <!-- Снаряд - это svg прямоугольник --> <g transform={`translate(${bullet.x}, ${bullet.y}) rotate(${bullet.angle})`}> <rect width="3" height="5" fill="#212121" /> </g>
Поскольку снаряды у нас хранятся в массиве, нам понадобится итератор для их отображения. В svelte для таких случаев есть директива Each. Подробнее в документации.
// Проходим по массиву bulletList, записывая каждый объект в переменную bullet. // Выражение в скобках указывает на id каждого объекта, так svelte может оптимизировать вычисления и обновлять только то, что действительно обновилось. // Аналог key из мира React {#each $bulletList as bullet (bullet.id)} <Bullet {bullet}/> {/each}
<script> import Cannon from "./Cannon.svelte"; // Импортируем компонент снаряда import Bullet from "./Bullet.svelte"; // импортируем список снарядов из хранилища import { bulletList } from "../stores/cannon"; </script> <style> .container { flex-grow: 1; display: flex; flex-direction: column; justify-content: flex-start; max-height: 100%; } </style> <div class="container"> <svg viewBox="0 0 480 800"> <!-- Добавим итерацию по нашему массиву снарядов --> {#each $bulletList as bullet (bullet.id)} <Bullet {bullet} /> {/each} <Cannon /> </svg> </div>
Теперь наша пушка умеет стрелять.

7. Враги
Отлично. Для минимального геймплея нам осталось добавить врагов. Давайте создадим хранилище src/stores/enemy.js.
import { writable } from "svelte/store"; // Массив врагов export const enemyList = writable([]); // Временная метка добавления последнего врага export const lastEnemyAddedAt = writable(0);
Создадим обработчики игрового цикла для врагов в 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)); }
Добавим обработчики врагов в наш игровой цикл.
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 по аналогии со снарядом.
<script> // В переменную enemy будем принимать объект, описывающий врага export let enemy; </script> // Отобразим прямоугольник с врагом, выполнив трансформацию по текущим координатам. <g transform={`translate(${enemy.x}, ${enemy.y})`} > <rect width="30" height="30" fill="#212121" /> </g>
Осталось импортировать компонент врага, массив с объектами врагов в наше игровое поле и отобразить их в цикле Each
<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> <style> .container { flex-grow: 1; display: flex; flex-direction: column; justify-content: flex-start; max-height: 100%; } </style> <div class="container"> <svg viewBox="0 0 480 800"> <!-- Добавим итерацию по нашему массиву врагов --> {#each $enemyList as enemy (enemy.id)} <Enemy {enemy} /> {/each} {#each $bulletList as bullet (bullet.id)} <Bullet {bullet} /> {/each} <Cannon /> </svg> </div>
Враг наступает!

8. Столкновения
Пока наши снаряды пролетают мимо, не причинив никакого вреда врагам.
Самое время добавить обработку столкновений. Общая игровая логика будет жить в файле src/gameLoop/game.js. Описание методики расчета столкновений можно прочитать на MDN
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); } }); }); }
Осталось добавить обработчик столкновений в игровой цикл.
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); }
Отлично, наши снаряды научились поражать цель.

9. Что дальше
Если вы дожили до этого момента и не потеряли интерес к нашим квадратным войнам, то у меня есть список ToDo на самостоятельное изучение:
- Добавить обработку проигрыша, когда один из врагов добрался до нижней границы экрана;
- Добавить подсчет очков;
- Добавить экран старта и окончания игры с выводом текущих и максимально набранных очков;
- Добавить анимацию убийства врага. В svelte есть крутые штуки для этого;
- Добавить управление с клавиатуры;
- Добавить логику увеличения интенсивности появления и скорости движения врагов с каждым убитым. Постепенное увеличение сложности добавит реиграбельности.
Мою реализацию этого списка вы можете посмотреть на github и в демо.
Заключение
Эту игру, в качестве обучающего примера, я пытался сделать на React. Из коробки мне не удалось завести игру в 60 FPS, а вот со Svelte получилось с первой попытки.
Попробуйте Svelte прямо сейчас, вам понравится.
