Хабрахабр

Разрабатываем игру на 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

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

Если вы не хотите разворачивать среду локально, то можете использовать онлайн песочницы 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
</style>
<!-- разметка компонента -->
<h1>Hello {name}!</h1>

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

src/App.svelte

<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, в которой будут храниться наши компоненты
В этой папке создадим два файла, которые будут содержать игровое поле и элементы управления.

src/components/GameField.svelte

<div>GameField</div>

src/components/Controls.svelte

<div>Controls</div>

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

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

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

<Controls />

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

src/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. Элементы управления

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

src/assets/Bullet.svelte

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

src/assets/LeftArrow.svelte

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

src/assets/RightArrow.svelte

<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.
Мы будем принимать обработчики событий из родительского компонента. Еще нам понадобится переменная active, которая будет отображать, нажата кнопка или нет. Объявим переменные start и release, куда будем принимать обработчики событий начала и окончания нажатия.

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

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

<div> <slot />
</div>

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

<div on:mousedown={start} on:touchstart={start} on:mouseup={release} on:touchend={release} class={`iconButton ${active ? 'active' : ''}`}> <slot />
</div>

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

src/components/IconButton.svelte

<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 и сверстаем расположение.

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>

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

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

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

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.

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>

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

src/GameField.svelte

<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. Игровой цикл

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

Этот функционал мы будем использовать в компонентах. Используя хранилище можно создавать подписку на значение. Для установки значения будем использовать метод .set() переменной.
Обновить значение можно вызвав метод .update(), который на вход принимает функцию, в первый аргумент которого передается текущее значение. Пока для чтения значения переменной будем использовать функцию get. Все остальное — чистый 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");

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

<IconButton start={setDirectionLeft} release={resetDirection} active={$direction === 'left'}> <LeftArrow />
</IconButton>
<IconButton start={setDirectionRight} release={resetDirection} active={$direction === 'right'}> <RightArrow />
</IconButton>

Этот синтаксис делает значение реактивным, автоматически создавая подписку на изменения. Мы использовали выражение $ для переменной $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> <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 На данный момент при нажатии у нашей кнопки происходит выделение, но пушка еще не поворачивается.

src/components/Cannon.svelte

<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();

App.svelte

<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 />

Наша пушка начала двигаться.
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);

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

<IconButton start={startFire} release={stopFire} active={$isFiring}> <Bullet />
</IconButton>

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

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

src/components/Bullet.svelte

<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}

src/components/GameField.svelte

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

Теперь наша пушка умеет стрелять.
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> // Отобразим прямоугольник с врагом, выполнив трансформацию по текущим координатам.
<g transform={`translate(${enemy.x}, ${enemy.y})`} > <rect width="30" height="30" fill="#212121" />
</g>

Осталось импортировать компонент врага, массив с объектами врагов в наше игровое поле и отобразить их в цикле 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> <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>

Враг наступает!
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 и в демо.

Заключение

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть