Чуть больше месяца назад вышел релиз Svelte 3. Хороший момент для знакомства, — подумал я и пробежался по отличному туториалу, который еще и переведен на русский.


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


image


0. Для нетерпеливых


Репозиторий туториала
Репозиторий с дополнениями
Демо


1. Подготовка


Клонируем шаблон для разработки


git clone https://github.com/sveltejs/template.git

Устанавливаем зависимости.


cd template/
npm i

Запускаем dev сервер.


npm run dev

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


Если вы не хотите разворачивать среду локально, то можете использовать онлайн песочницы codesandbox и stackblitz, которые поддерживают Svelte.


2. Каркас игры


Папка src состоит из двух файлов main.js и App.svelte.
main.js — это точка входа в наше приложение. Во время разработки мы ее трогать не будем. Здесь компонент App.svelte монтируется в body документа.
App.svelte — это компонент svelte. Шаблон компонента состоит из трех частей:


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

<style>
    /* CSS стили компонента */
    h1 {
        color: purple;
    }
</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. Элементы управления


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


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

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


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


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


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


// импортируем модуль изменяемой переменной
import { writable } from "svelte/store";

// Объявляем переменную с начальным значением null
export const isPlaying = writable(null);

Создадим папку src/stores/, здесь будут храниться все изменяемые значения нашей игры.
Создадим файл src/stores/game.js, в котором будут храниться переменные, отвечающие за общее состояние игры.


src/stores/game.js
// импортируем модуль изменяемой переменной
import { writable } from "svelte/store";

// Запущен в данный момент игровой цикл или нет, может принимать значения true/false
export const isPlaying = writable(false);

Создадим файл src/stores/cannon.js, в котором будут храниться переменные, отвечающие за состояние пушки


src/stores/cannon.js
// импортируем модуль изменяемой переменной
import { writable } from "svelte/store";

// Отвечает за текущее направление, в котором нужно поворачивать пушку. 
// Будет принимать значения 'left', 'right', null, устанавливается нашими кнопками
export const direction = writable(null);

// Текущий угол поворота пушки
export const angle = writable(0);

Svelte позволяет создавать пользовательские хранилища, включающие логику работы. Подробнее об этом можно почитать в учебнике. У меня не получилось красиво вписать это в концепцию игрового цикла, поэтому в хранилище мы только объявляем переменные. Все манипуляции с ними мы будем производить в разделе src/gameLoop.


Игровой цикл будет планироваться с помощью функции requestAnimationFrame. На вход будет подаваться массив из функций, описывающий логику игры. По завершении игрового цикла, если игра еще не закончена, планируется следующая итерация. В игровом цикле мы будет обращаться к значению переменной isPlaying, чтобы проверить, не закончилась ли игра.


Используя хранилище можно создавать подписку на значение. Этот функционал мы будем использовать в компонентах. Пока для чтения значения переменной будем использовать функцию get. Для установки значения будем использовать метод .set() переменной.
Обновить значение можно вызвав метод .update(), который на вход принимает функцию, в первый аргумент которого передается текущее значение. Подробнее в документации. Все остальное — чистый JS.


src/gameLoop/gameLoop.js
// Импортируем переменную из хранилища
import { isPlaying } from '../stores/game';
// с помощью функции get можно получить текущее значение стора, без подписки.
import { get } from 'svelte/store';

// Функция отвечает за игровой цикл
function startLoop(steps) {
  window.requestAnimationFrame(() => {
    // Проходим по массиву игровых шагов
    steps.forEach(step => {
      // Если шаг функция - запускаем
      if (typeof step === 'function') step();
    });
    // Если игра не остановилась, планируем следующий цикл
    if (get(isPlaying)) startLoop(steps);
  });
}

// Функция отвечает за запуск игрового цикла
export const startGame = () => {
  // Устанавливаем переменную, которая хранит состояние игры в true
  isPlaying.set(true);
  // запускаем игровой цикл. Пока массив шагов пустой
  startLoop([]);
};

// Функция отвечает за остановку игрового цикла
export function stopGame() {
  // Устанавливаем переменную, которая хранит состояние игры в false
  isPlaying.set(false);
}

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


src/gameLoop/cannon.js
// с помощью функции get можно получить текущее значение стора, без подписки.
import { get } from 'svelte/store';

// Импорт всех переменных из хранилища cannon
import { angle, direction } from '../stores/cannon.js';

// Функция обновления угла поворота пушки
export function rotateCannon() {
  // Получаем текущий угол поворота
  const currentAngle = get(angle);
  // В зависимости от того, какая кнопка зажата, обновляем угол поворота
  switch (get(direction)) {
    // Если зажата кнопка "влево" и угол поворота меньше -45°, 
    // то уменьшаем угол поворота на 0.4
    case 'left':
      if (currentAngle > -45) angle.update(a => a - 0.4);
      break;
    // Если зажата кнопка "вправо" и угол поворота меньше 45°,
    // то увеличиваем угол поворота на 0.4
    case 'right':
      if (currentAngle < 45) angle.update(a => a + 0.4);
      break;
    default:
      break;
  }
}

Теперь добавим наш обработчик поворота пушки в игровой цикл.


import { rotateCannon } from "./cannon";
/* ... */
export const startGame = () => {
  isPlaying.set(true);
  startLoop([rotateCannon]);
};

Текущий код игрового цикла:


src/gameLoop/gameLoop.js
import { isPlaying } from '../stores/game';
import { get } from 'svelte/store';

import { rotateCannon } from './cannon'; // импортируем обработчик поворота пушки

function startLoop(steps) {
  window.requestAnimationFrame(() => {
    steps.forEach(step => {
      if (typeof step === 'function') step();
    });
    if (get(isPlaying)) startLoop(steps);
  });
}

export const startGame = () => {
  isPlaying.set(true);
  startLoop([rotateCannon]); // Добавим обработчик в игровой цикл
};

export function stopGame() {
  isPlaying.set(false);
}

У нас есть логика, которая умеет поворачивать пушку. Но мы еще не связали ее с нажатием кнопок. Самое время сделать это. Обработчики событий нажатий будем добавлять в src/components/Controls.svelte.


import { direction } from "../stores/cannon.js"; // импортируем переменную направления поворота из хранилища

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

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


<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 и в демо.


Заключение


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

Комментарии (30)


  1. vintage
    29.05.2019 09:20

    Ок, пришёл менеджер и говорит: надо сделать сплитскрин, чтобы два человека могли параллельно играть на одном экране. Что вы будете делать с вашими синглтон стором и синглтон лупом?


    1. Zoolander
      29.05.2019 10:51

      синглтон луп то сам по себе вещь нормальная, просто нужно не редактировать его, а иметь механизм загрузки и выгрузки задач для него. В фреймворке Phaser единый loop, который обрабатывает методы update всех живых объектов. Так что если бы мне поставили задачу расширять именно такой пример — я бы писал что-нибудь, что регистрировало бы функции в списке, по которому бы проходился loop

      вот что касается стора — тут я в затруднении, поскольку недостаточно хорошо читаю Svelte/React-подобные исходники. По моим прикидкам, для сплитскрина один игровой экран должен быть вью-компонентом с инкапсулированным состоянием. Два таких компонента можно связать с общей моделью состояния, на изменения которого подписана логика соревнования и общий тайминг.

      А тут одна общая модель для всех? Можно ли не хранить сиюминутные состояния экрана в общем сторе, сделать какой-нибудь свой отдельный стор-обсервабл?

      если идти дальше и делать онлайн-матчи — то игровой экран придется сделать исполнителем для приходящих команд для изменения вью и отправителем команд нажатия — а логику обработки команд и внутреннего выносить на сервер. Но вряд ли кто-то даст денег под такой концепт игры )

      Я не автор, я использовал вопрос как задачку для себя, поскольку работаю в похожей области и на горизонте как раз наклевывается мультиплеер. Если где-то мои рассуждения кажутся глупыми — не стесняйтесь указать более изящное решение.


      1. vintage
        29.05.2019 11:49

        Более изящно делать компонент приложения самодостаточным, не зависящим от глобального состояния.


        1. Zoolander
          29.05.2019 11:55

          да, я это и имел в виду
          хотя у меня был когда-то период God Object, а затем меловая эпоха толстых контроллеров


        1. Zoolander
          29.05.2019 12:13

          В порядке шутки — грязный, но рабочий хак. Пихаем эту игру в отдельный ифрейм на странице, пихаем вторую игру во второй ифрейм, дописываем на Window.postMessage общение с родительской страницей — а уже в родительской странице делаем общую логику и даже полное отключение компонента.

          Я так не делал и никому не советую, но в разумных пределах iframes + postMessage иногда работает как решение для сборки самых разных компонентов от самых разных разработчиков на одной странице. Как минимум, я знаю кейс, когда игру, которая писалась на игровом фреймворке, так подключали к странице, на которой крутился обычный фронтенд — и уже на ней показывались баллы, время, отведенное на игру и другие параметры, которые менялись на самой странице согласно гениальному плану организаторов всего проекта.

          PS: не делал и не советую — в смысле, рефакторить такие кейсы подобным образом.


        1. serf
          29.05.2019 13:00

          Поддерживаю но уточню что «изящно» звучит как-то по маркетинговому здесь было ругательство. Давайте лучше скажем правильно/дальновидно/грамотно/предусмотрительно/разумно.


  1. Sartor
    29.05.2019 09:27

    Хороший пример, но нужно больше сложностей и проблем из реального мира. Фреймворк всё больше мне нравится.


  1. Zoolander
    29.05.2019 10:08

    Сколько FPS было в React-версии?
    На каких машинах тестировали?


    1. sanReal Автор
      29.05.2019 10:18
      +1

      Откопал старый проект ) https://stackblitz.com/edit/tanks3
      Проекты, конечно, разные, но тем не менее, FPS монитор хрома мне выдает около 55 FPS.
      Macbook air 2013


      1. Zoolander
        29.05.2019 10:29

        ну это еще приличный FPS )


        1. sanReal Автор
          29.05.2019 10:30

          В React у меня сильно проседал FPS на событиях клавиатуры, даже с тротлингом


          1. Zoolander
            29.05.2019 10:55

            по отладчику было видно, в чем проседает React в реалтайме? Какое там узкое место?


            1. sanReal Автор
              29.05.2019 10:57
              +1

              Не, так глубоко не копал ) Просто понял, что React не для игр и забил )


              1. Zoolander
                29.05.2019 11:06

                из того, что я читал, понял, что у фреймворков с виртуальным DOM уходит много сил на поддержание слоя абстракции — но лично не проверял.


              1. Zoolander
                29.05.2019 11:19

                тут проблема в том, что обновление DOM сама по себе довольно затратная операция и со сложным макетом, особенно при эффектах типа анимированной смены экранов, подтормаживание будет заметно, даже если писать на ванилле (я писал) — поэтому в принципе для реал-тайма лучше взять что-то, что рисует на canvas хотя бы. Если будете дальше идти в том направлении и нужна будет скорость анимаций в html5, то попробуйте Phaser.js — но вот с управлением состоянием игры там придется рисовать свои архитектурные велосипеды


              1. Carduelis
                29.05.2019 21:59
                +1

                А как можно копать «не глубоко», не открыв Profiler?


  1. cepro
    29.05.2019 12:14

    Спасибо за замечательный туториал!
    Не поделитесь бест-практис по настройке среды разработки под Svelte?
    А то в IntelliJ * как-то оно не заиграло с полоборота (не смотря на установку такого плагина)…


    1. sanReal Автор
      29.05.2019 12:22

      Писал в VS code с плагином Svelte. Еще онлайн песочницы неплохо дружат со Svelte, в статье есть ссылки.
      По поводу других IDE не подскажу (


      1. cepro
        29.05.2019 13:53

        Благодарю!


  1. justboris
    29.05.2019 20:24

    Добавим компонент кнопки
    <div>

    Все же лучше использовать <button>. Каждый раз когда верстальщик делает кликабельный див вместо кнопки, pepelsbey негодует. Вот хороший доклад на эту тему, кстати: https://www.youtube.com/watch?v=ssJsjGZE2sc


    1. sanReal Автор
      29.05.2019 20:30

      Согласен. Срезал угол, чтобы не накидывать стилей…


      1. pepelsbey
        29.05.2019 20:44

        Не срезайте углы, из-за вашей лени (оптимизации?) кто-то не сможет пользоваться интерфейсами.


        А ещё, глядя на ваш SVG, видно, что вы в душе не понимаете, что там происходит. Разберитесь, там много лишнего очень красиво выровнено по строкам.


        1. sanReal Автор
          29.05.2019 21:07
          +1

          Спасибо за замечания. Исправил в меру своих знаний.


        1. Zoolander
          29.05.2019 21:17

          похоже, вы имеете в виду, что rect прекрасно можно использовать с transform и для этого не нужно заворачивать его в group

          что касается интерфейсов — хотелось бы услышать разбор, какой именно accessibility из фронтенда нужен в играх такого рода, что в посте? Какой use case может возникнуть и как его можно успешно реализовать, учитывая, что в таких играх нужна очень быстрая реакция?


          1. pepelsbey
            30.05.2019 23:51
            +1

            учитывая, что в таких играх нужна очень быстрая реакция

            Больше всего фронтенду любых интерфейсов нужно, чтобы мы не делали выводов о людях, о которых ничего не знаем. Я столько дичи слышал про то, что слепые и прочие «странные люди» не ходят в интернет, не смотрят Ютуб, не покупают в интернете. Нет, это не так. Делайте хорошо и доступно, а люди разберутся, как им это использовать.


          1. pepelsbey
            31.05.2019 00:02

            имеете в виду, что rect прекрасно можно использовать с transform

            Нет, гораздо больше. Сравните:


            bullet-before.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>


            1. sanReal Автор
              31.05.2019 00:40

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


              1. Атрибуты width и height подгоняют svg под нужный нам размер. Это можно сделать через css. У вас есть аргументы, почему это нужно выносить в css, особенно в контексте туториала? Да, можно изначально нарисовать в нужном размере, но я, к сожалению, не дизайнер, и взял готовое изображение.
              2. Атрибут transform сдвигает треугольник, чтобы визуально он казался по центру круга. В случае с правой стрелкой еще и разворачивает.
                С трансформацией:
                image
                Без трансформации:
                image
                Опять же, это можно вынести в css. Я не знаю весомых аргументов делать это в css.
              3. Поскольку в туториале я использую svg в качестве .svelte компонента, код будет превращен при билде в минифицированный js, и отступы не имею значения.


  1. PaulMaly
    30.05.2019 23:58
    +1

    Очень круто! Спасибо большое что поделились опытом.

    Одно дополнение, когда приходится писать конктрукцию вида:

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


    Лучше ее заменить на более чистую версию:

    <button
      use:hold={[start, release]}
      class={`iconButton ${active ? 'active' : ''}`}>
      <slot />
    </button>
    
    <script>
      import hold from './actions/hold.js'
    </script>
    


    Реализация экшена:

    export default function(node, [ start, end ]) {
      node.addEventListener('mousedown', start);
      node.addEventListener('touchstart', start);
      node.addEventListener('mouseup', end);
      node.addEventListener('touchend', end);
      return {
        destroy() {
          node.removeEventListener('mousedown', start);
          node.removeEventListener('touchstart', start);
          node.removeEventListener('mouseup', end);
          node.removeEventListener('touchend', end);
        }
      };
    }
    


    1. PaulMaly
      01.06.2019 23:20
      +1

      Сразу не заметил, но в этом же куске кода лучше сделать так:

      <button use:hold={[start, release]} class:active class="iconButton">
        <slot />
      </button>
      

      С использованием директивы class: и ее укороченной формы.


      1. sanReal Автор
        02.06.2019 08:12
        +1

        Это невероятно! Я почему-то думал, что два свойства class не получится навесить, и даже не попробовал. Вы открыли мне глаза, полюбил svelte еще больше)