Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Чуть больше месяца назад вышел релиз 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"
style="enable-background:new 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"
style="enable-background:new 0 0 292.359 292.359;"
transform="translate(5 0) rotate(180)">
<g>
<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" />
</g>
</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;
}
.active {
/* Устанавливаем фон для состояния, когда кнопка нажата */
background-color: #bdbdbd;
}
</style>
Кнопка представляет собой div элемент, внутри которого отображается контент, переданный из родительского компонента. Место, где будут монтироваться переданный контент обозначается тегом <slot/>. Подробнее об элементе <slot/>.
<div>
<slot />
</div>
Обработчики событий обозначаются через директиву on:, например, on:click.
Мы будем обрабатывать события мыши и тач нажатия. Подробнее о привязке событий.
К базовому классу компонента будет добавляться класс active, если кнопка нажата. Назначить класс можно свойством class. Подробнее о классах
<div
on:mousedown={start}
on:touchstart={start}
on:mouseup={release}
on:touchend={release}
class={`iconButton ${active ? 'active' : ''}`}>
<slot />
</div>
В итоге наш компонент будет выглядеть следующим образом:
<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;
}
.active {
background-color: #bdbdbd;
}
</style>
<div
on:mousedown={start}
on:touchstart={start}
on:mouseup={release}
on:touchend={release}
class={`iconButton ${active ? 'active' : ''}`}>
<slot />
</div>
Теперь импортируем наши иконки и элемент кнопки в 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 прямо сейчас, вам понравится.