Разрабатываем игру на Svelte 3
Чуть больше месяца назад вышел релиз 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. Шаблон компонента состоит из трех частей:
Hello {name}!
Стили компонента изолированы, но есть возможность назначить глобальные стили директивой : global (). Подробнее о стилях.
Добавим общие стили для нашего компонента
Hello {name}!
Давайте создадим папку src/components, в которой будут храниться наши компоненты
В этой папке создадим два файла, которые будут содержать игровое поле и элементы управления.
GameField
Controls
Импорт компонента осуществляется директивой
import Controls from "./components/Controls.svelte";
Для отображения компонента достаточно вставить тег компонента в разметку. Подробнее о тегах.
Теперь импортируем и отобразим наши компоненты в App.svelte.
3. Элементы управления
Компонент Controls.svelte будет состоять из трех кнопок: движение влево, движение вправо, огонь. Иконки кнопок будут отображаться svg элементом.
Создадим папку src/asssets, в которую добавим наши svg иконки.
Добавим компонент кнопки src/components/IconButton.svelte.
Мы будем принимать обработчики событий из родительского компонента. Для того, чтобы можно было зажать кнопку, нам понадобятся два обработчика: начало нажатия и конец нажатия. Объявим переменные start и release, куда будем принимать обработчики событий начала и окончания нажатия. Еще нам понадобится переменная active, которая будет отображать, нажата кнопка или нет.
Стилизуем наш компонент
Кнопка представляет собой div элемент, внутри которого отображается контент, переданный из родительского компонента. Место, где будут монтироваться переданный контент обозначается тегом
Обработчики событий обозначаются через директиву on: , например, on: click.
Мы будем обрабатывать события мыши и тач нажатия. Подробнее о привязке событий.
К базовому классу компонента будет добавляться класс active, если кнопка нажата. Назначить класс можно свойством class. Подробнее о классах
В итоге наш компонент будет выглядеть следующим образом:
Теперь импортируем наши иконки и элемент кнопки в src/components/Controls.svelte и сверстаем расположение.
Наше приложение должно выглядеть так:
4. Игровое поле
Игровое поле представляет собой svg компонент, куда мы будем добавлять наши элементы игры (пушку, снаряды, противников).
Обновим код src/components/GameField.svelte
Создадим пушку src/components/Cannon.svelte. Громко сказано для прямоугольника, но тем не менее.
Теперь импортируем нашу пушку на игровое поле.
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, как описано в документации.
Мы использовали выражение $ для переменной $direction. Этот синтаксис делает значение реактивным, автоматически создавая подписку на изменения. Подробнее в документации.
На данный момент при нажатии у нашей кнопки происходит выделение, но пушка еще не поворачивается. Нам необходимо импортировать значение angle в компонент Cannon.svelte и обновить правила трансформации transform
Осталось запустить наш игровой цикл в компоненте App.svelte.
import { startGame } from "./gameLoop/gameLoop";
startGame();
Ура! Наша пушка начала двигаться.
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);
Теперь добавим наши обработчики к кнопке, управляющей огнем, как мы делали это с кнопками поворота
Осталось отобразить снаряды на игровом поле. Сначала создадим компонент снаряда
Поскольку снаряды у нас хранятся в массиве, нам понадобится итератор для их отображения. В svelte для таких случаев есть директива Each. Подробнее в документации.
// Проходим по массиву bulletList, записывая каждый объект в переменную bullet.
// Выражение в скобках указывает на id каждого объекта, так svelte может оптимизировать вычисления и обновлять только то, что действительно обновилось.
// Аналог key из мира React
{#each $bulletList as bullet (bullet.id)}
{/each}
Теперь наша пушка умеет стрелять.
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 по аналогии со снарядом.
// Отобразим прямоугольник с врагом, выполнив трансформацию по текущим координатам.
Осталось импортировать компонент врага, массив с объектами врагов в наше игровое поле и отобразить их в цикле Each
Враг наступает!
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 прямо сейчас, вам понравится.