Pull to refresh

Разрабатываем игру на Svelte 3

Website developmentJavaScriptGame developmentSvelteJS
Tutorial

Чуть больше месяца назад вышел релиз 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 прямо сейчас, вам понравится.

Tags:javascriptsveltegame developmentreactive programming
Hubs: Website development JavaScript Game development SvelteJS
Total votes 27: ↑26 and ↓1 +25
Views10.8K

Comments 32

Only those users with full accounts are able to leave comments. Log in, please.

Popular right now

Tech Lead (Development)
from 180,000 ₽Game InsightRemote job
Frontend Software Development Engineer
from 3,000 to 5,000 $EnnablRemote job
Software Development engineer
from 4,000 to 5,000 $DataDirect Networks Inc. (DDN)Remote job
Frontend Development Lead
to 550,000 ₽NUTSonМоскваRemote job
JavaScript разработчик
from 180,000 ₽SportrecsМоскваRemote job