Предисловие

Эта статья была написана без малого год назад, чуть подправлена несколько месяцев назад, но была отложена в долгий ящик. С тех пор многое изменилось, например, русскоязычная документация в настоящий момент вроде бы не поддерживается в актуальном состоянии. Был опубликован новый интерактивный учебник сразу по Svelte и SvelteKit вместе взятым. Текущая версия SvelteKit уже достигла 1.20, но в остальном, насколько я знаю, материал статьи все еще актуален.

Введение

Статья рассчитана на начинающих программистов, уже знакомых с git, html/css, typescript и nodejs. В статье будет много кода и мало текста - все, как вы любите - так что приготовьтесь поработать. Некоторые места в коде я прокомментирую, чтобы облегчить вам его понимание. Конечно, для разработки игр существуют и более подходящие специализированные инструменты, нежели фронтенд-фреймворк общего назначения Svelte, но, в демонстрационно-учебных целях, почему бы и нет?..

Не так давно, зацепив где-то краем уха слово "Тетрис", я внезапно подвергся приступу ностальгии, вспомнив свои первые компьютерные игры, среди которых был и "Тетрис". Это были удивительные времена, когда на играх подобного уровня разработчики могли заработать буквально миллионы долларов, а игроки - совершенно невозбранно прожигать свое драгоценное время. Сам я никогда не был заядлым игроком, но изредка - в годы относительно беззаботной юности - все же мог позволить себе "позависать". Однако лирику в сторону, давайте напишем свой Тетрис, и не на чем попало, а на великолепном Svelte. Если вы еще не слышали об этом блистательном будущем фронтенд-разработки (да, я продолжаю в это верить), то поспешите познакомиться с его интерактивным руководством на английском, либо на русском языке. В дальнейшем я буду ссылаться только на англоязычную версию документации, поскольку русскоязычная, в силу известных событий, более активно не поддерживается, насколько я знаю. В случае многих других фреймворков такая отсылка начинающих программистов к документации могла бы выглядеть форменным издевательством, но, в случае Svelte, это не так. Для понимания этой статьи вам будет достаточно беглого ознакомления с первыми восемью главами. Документация Svelte, как и он сам, компактна, практична, и понятна. К слову сказать, совсем недавно был выпущен 1-й релиз "метафреймворка" SvelteKit, который является аналогом Next.js и NuxtJS, но со Svelte в качестве основы. Документация SvelteKit тоже имеет свой русскоязычный вариант, но, как и в случае Svelte, может отставать от актуальной версии.

В этой статье я реализую "свой", выхолощенный вариант Тетриса, не заморачиваясь на сколь-нибудь детальную проработку чего-бы то ни было, начиная от игровой логики и заканчивая внешним видом, лишь с целью продемонстрировать, "как можно" на Svelte, при этом попутно не утопив читателя в малозначащих деталях. Сразу скажу, что я не настаиваю на единственно верных паттернах использования Svelte. На Svelte, как и на многих других фреймворках, некоторые вещи можно делать разными способами. Разобравшись в предложенном коде, вы легко сможете модифицировать его на свое усмотрение, доработав игру до ваших высоких стандартов, или разработать свою собственную.

Создание проекта

В качестве рабочей операционной системы я использую один из самых популярных дистрибутивов Linux - Ubuntu. Все команды, которые будут приведены ниже, предназначены для выполнения в терминале этой ОС.

Прежде всего, склонируйте репозиторий уже готового приложения на свой компьютер:

git clone git@github.com:nodeperson/svelte-tetris.git

После чего перейдите в директорию склонированного репозитория командой

cd svelte-tetris

и выполните установку необходимых пакетов

npm install

После того, как установка зависимостей проекта будет завершена, можно запустить приложение командой

npm run dev

После запуска в консоли будет выведен адрес, по которому вы сможете открыть приложение в своем браузере

Local:   http://localhost:5173/

В моем случае это http://localhost:5137 У вас может быть другой порт, это не страшно, это бывает. Открыв браузер, и вставив в адресную строку этот адрес, вы увидите запущенное приложение. У меня оно выглядит так:

Чтобы игра "ожила", кликните мышкой в окно браузера и таким образом установите на ней фокус, после чего нажмите кнопку "Pause" на вашей клавиатуре. Через пару секунд первая фигурка начнет свое движение вниз.

Создание Svelte-приложения "с нуля"

Если вы хотите выполнить всю процедуру разработки приложения "с нуля", то сначала вам нужно будет создать Svelte-приложение. Для этого в терминале вашей ОС выполните команду

npm init vite

В ответ на приглашение от vite ...

? Project name:vite-project

... вместо "vite-project" задайте свое название проекта svelte-tetris. Затем, кнопкой "стрелка вниз", выберите в предложенном списке фреймворков svelte, и, наконец, svelte-ts. После чего последовательно выполните команды

cd svelte-tetris
npm install

Откройте проект в своем редакторе кода - в моем случае это VS Code с установленным расширением "Svelte for VS Code"- и удалите содержимое папок srs/lib и src/assets, файл src/app.css а также полностью удалите содержимое файла src/App.svelte. В файле src/main.ts удалите строку import './app.css', выполняющую импорт более несуществующего файла src/app.css. В файле index.html, который находится в корне проекта, замените содержимое тэга title на название вашего проекта - Tetris. Теперь ваш проект девственно чист и готов к дальнейшей работе.

Основные игровые сущности

Приступим к созданию игры. Полагаю, что к этому моменту вы уже посмотрели статью о Тетрисе на Википедии, и, как минимум, представляете правила игры, и то, как примерно она должна выглядеть. Еще раз отмечу, что игру я буду "конструировать", опираясь на собственные воспоминания, представления и пару картинок с игровым процессом, которые можно увидеть в Youtube или найти в Google. Она будет похожа на классический Тетрис, но с некоторыми особенностями, обусловленными стремлением к упрощению.

Итак, у нас имеется некоторая фигурка, падающая по тику игрового таймера внутри игрового "стакана" или "коробки", как вам больше нравится. Скорость тика игрового таймера определяется текущим уровнем игры, который, в свою очередь, определяется числом полностью заполненных линий. Падающие фигурки можно крутить кнопкой "стрелка вверх". Можно ускорить падение фигурки, нажимая кнопку "стрелка вниз", либо вообще "уронить" фигурку на дно, нажав "пробел". Задача игрока - укладывать фигурки на дне стакана без пропусков. Заполненные без пропусков линии стираются, освобождая место в стакане. Чем больше линий без пропуска вы заполнили, тем выше будет ваш игровой уровень, и, соответственно, скорость падения фигурок по таймеру. В качестве мерила успеха игрока я использую число заполненных линий, однако вы можете следовать классическим правилам и вести учет так, как вам больше нравится.

Прежде всего, определимся с общими настройками игры. Я отнес к ним размер стакана/коробки, который будет представлен у нас прямоугольником с высотой BoxHeight и шириной BoxWidth. Единицы измерения - это количество "квадратиков", из которых сформирован Стакан, в соответствующем измерении.

В дальнейшем я буду называть игровой стакан/коробку "стаканом", хотя в коде он будет у меня Box. Мне кажется, что по-русски "стакан" звучит лучше, а по-английски наоборот - лучше писать "Box".

Минимально и максимально возможные длительности пауз в миллисекундах между тиками игрового таймера в зависимости от текущего уровня игры - MinHeartbeat и MaxHeartbeat, а также минимальный и максимальный игровой уровни MinLevel и MaxLevel. Константа CompletedLinesToAdvanceLevel определяет число линий, которые нужно заполнить, дабы перейти на следующий игровой уровень.

Основные настройки игры хранятся в файле src/lib/settings.ts

src/lib/settings.ts
export const BoxWidth = 10
export const BoxHeight = 20

export const MaxHeartbeat = 2000
export const MinHeartbeat = 500

export const MinLevel = 1
export const MaxLevel = 10

export const CompletedLinesToAdvanceLevel = 3

В файле src/lib/entities.ts находятся основные типы, используемые в приложении.

src/lib/entities.ts
export enum BoxCellState {
    empty,
    figure,
    frozen
}

type Vector<T> = Array<T>

export type Matrix<T> = Array<Vector<T>>

export type FigureView = Matrix<number>

export type GameView = Matrix<BoxCellState>

Перечисление BoxCellState описывает состояние ячеек в Стакане, Vector - это строка в таблице, Matrix - таблица/матрица/двумерный массив.

Файл src/lib/figures.ts содержит в себе все, что относится к сущности Figure. Здесь же размещены определения для внешнего вида разных фигурок в виде двумерных массивов, и вспомогательные функции для работы с Фигурой, которые пригодятся в дальнейшем.

src/lib/figures.ts

import type { FigureView } from './entities';

interface IFigures {
    [key: string]: FigureView
}
export const Figures: IFigures = {
    i: [
        [1, 1, 1, 1],
    ],
    j: [
        [1, 0, 0],
        [1, 1, 1],
    ],
    l: [
        [0, 0, 1],
        [1, 1, 1],
    ],
    o: [
        [1, 1],
        [1, 1]
    ],
    s: [
        [0, 1, 1],
        [1, 1, 0]
    ],
    t: [
        [0, 1, 0],
        [1, 1, 1]
    ],
    z: [
        [1, 1, 0],
        [0, 1, 1]
    ]
}

export function figureCellIsSolid(cell: number){
    return cell === 1;
}

export function figureCellIsEmpty(cell: number){
    return !figureCellIsSolid(cell);
}

export function figureWidth(figure: FigureView): number {
    return figure[0].length;
}

export function figureHeight(figure: FigureView): number {
    return figure.length;
}

export function randomFigure(): FigureView {
    const names = Object.keys(Figures);
    const randomIndex = Math.round(Math.random() * (names.length - 1));
    const randomName = names[randomIndex];
    return Figures[randomName];
}

Внимательный читатель заметил, конечно же, некоторую "несправедливость" или, если угодно, "нелогичность" в том, что состояния ячеек Стакана у меня именованы и перечислены в BoxCellState, а вот состояние ячеек Фигуры обозначены как 1 и 0. Хотя, казалось бы, так и напрашивается что-то вроде enum FigureCellState = { solid, empty }. Почему же я поступил по-разному в двух столь похожих ситуациях? На самом деле, все объясняется моим личным предпочтением. Просто мне кажется, что единицы и нули более удобны и наглядны для представления фигур. Посмотрите в файле srs/lib/figures.ts, как заданы значения для разных фигур в константе Figures в виде двумерных массивов . Сейчас в ячейках этих табличек - двумерных массивов - олицетворяющих собой фигурки, единицы и нули, а могли бы вместо них быть FigureCellState.solid и FigureCellState.empty соответственно. Согласитесь, получилось бы не столь наглядно.

В процессе игры нам нужно будет иметь возможность поворачивать фигуры на 90 по часовой стрелке, нажимая клавишу "стрелка вверх". Не мудрствуя лукаво, я назвал эту функцию turn90 и поместил ее в файле src/lib/linalg.ts

src/lib/linalg.ts
import type { Matrix } from './entities';

// транспонирование: столбцы становятся строками, а строки - столбцами
function transpose<T>(m: Matrix<T>) {
    let transposed: Matrix<T> = [];
    m[0].forEach((_, j) => {
        transposed.push([])
        m.forEach((_, i) => {
            transposed[j].push(m[i][j])
        })
    })
    return transposed;
}

// поворот на 90 градусов <--> reverseColumns(transpose(view))
export function turn90<T>(m: Matrix<T>): Matrix<T> {
    return transpose(m).map(line => line.reverse());
}
Почему у файла такое странное название linalg?..

Потому что изначально предполагалось использование "линейной алгебры", "матрицы поворота" и некоторых других порицаемых в приличном обществе бранных словосочетаний. Однако, еще на самом раннем этапе анализа того, как именно выглядят повернутые фигурки в классическом Тетрисе я заметил, что поворот фигуры в Тетрисе равносилен транспонированию матрицы с последующим изменением порядка следования столбцов на обратный. Поэтому потенциально возможные препирательства с линалгеброй были отложены в сторону, функция поворота реализована так, как реализована, а ее дурная наследственность отобразилась в названии файла, которому было поручено хранить эту функцию. Конечно, можно было бы разместить функцию и в src/lib/figures.ts, хуже от этого не стало бы, но что сделано, то сделано.

В дальнейшем нам понадобится пара вспомогательных функций из src/lib/utils.ts

src/lib/utils.ts
import type { FigureView } from "./entities";
import { figureWidth } from "./figures";
import { BoxWidth } from "./settings";

export function initialColumn(figure: FigureView) {
    return Math.floor((BoxWidth - figureWidth(figure)) / 2);
}

export function deepClone<T>(any:T) {
    return <T>JSON.parse(JSON.stringify(any));
}

Функция initialColumn вычисляет, в какой именно колонке матрицы, описывающей Стакан, должна появляться фигурка при вводе ее в игру, а deepClone позволяет создать глубокую копию прямоугольного массива - матрицы. Вообще-то говоря, возможности deepClone несколько шире, нежели было заявлено выше, но нам потребуется только эта ее возможность. Что касается реализации, от которой у строгих адептов бескомпромиссной производительности наверняка полезут глаза на лоб от возмущения, могу сказать, что здесь я пошел по самому легкому пути, благо проект позволяет, хотя мог бы, например, воспользоваться методом cloneDeep из библиотеки lodash или даже методом structuredClone, о котором лично я узнал непосредственно во время написания этих строк. Теперь вы тоже узнали). Что касается initialColumn - то она вычисляет положение Фигуры, исходя из нашего пожелания размещать Фигуру примерно в центре стакана.

Хранилища

До сих пор в нашем приложении не было ничего Svelte-специфичного, если не принимать во внимание сам пустой проект, конечно же. Теперь пришло время и для Svelte. А говоря конкретнее - для такого важного понятия, как "хранилища" ("stores"). Надеюсь, что к этому моменту вы уже познакомились с первыми 8-ю главами документации Svelte, если нет - то сейчас самое время сделать это... Итак, вы познакомились с понятием "хранилищa", и готовы продолжать. Хранилища у нас расположены в директории src/store.

Хранилище Box

Хранилище Box, ответственное за игровой Стакан, хранится в файле src/store/box.ts

src/store/box.ts
import type { Writable } from "svelte/store";
import { writable, get } from "svelte/store";
import { BoxHeight, BoxWidth } from "../../src/lib/settings";
import { BoxCellState } from "../../src/lib/entities";
import type { GameView } from '../../src/lib/entities';

export const Box: Writable<GameView> = writable(Array(BoxHeight)
    .fill([])
    .map((_) => Array(BoxWidth).fill(BoxCellState.empty)))

export function clean() {
    Box.set(get(Box).map(line => line.map(cell => cell = BoxCellState.empty)))
}

Этот код создает экземпляр хранилища, который будет хранить в себе игровой Стакан в форме двумерного массива, каждая ячейка которого может иметь одно из состояний, перечисленных в BoxCellState.

В начале игры Стакан пуст, поэтому при его создании мы заполняем его ячейки значением BoxCellState.empty.

В дальнейшем нам понадобится функционал по очистке Стакана, я реализовал его здесь же, в функции clean.

Хранилище Figure

Хранилище Figure, ответственное за хранение состояния фигуры, которой предстоит управлять игроку, хранится в файле src/store/figure.ts

src/store/figure.ts

import { writable } from 'svelte/store';
import { randomFigure } from '../lib/figures';
import { initialColumn } from '../../src/lib/utils';

const view = randomFigure()

export const Figure = writable({
    view,
    location: {
        row: 0,
        column: initialColumn(view)
    }
})

Хранилище Figure хранит в себе текущий вид - "view" - фигуры, а также ее положение в Стакане: индекс строки row и номер колонки column. Поскольку для нас неважно, какая Фигура будет в начале игры, то выбираем ее случайным образом.

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

Хранилище Next

src/store/next.ts
import { writable } from 'svelte/store';
import { randomFigure } from '../../src/lib/figures';

export const Next = writable(randomFigure())

А чтобы вести учет достижений игрока, создадим хранилище Stats.

Хранилище Stats

src/store/stats.ts
import { writable } from 'svelte/store';
import { MinLevel } from '../../src/lib/settings';

const zero = {
    level: MinLevel, lines: 0
}

export const Stats = writable({ ...zero })

export function reset() {
    Stats.set(zero);
}

Здесь же я реализовал вспомогательную функцию reset, которая обнуляет статистику. Она понадобится нам при перезапуске игры.

Компоненты Svelte

Компонент Controller

Первый Svelte-компонент, который мы создадим, называется Controller и предназначен для обработки нажатий клавиш клавиатуры. Кроме функционала обработки нажатий, он также будет отображать на экране информацию о кнопках управления в игре.

К сожалению, на момент написания этой статьи в редакторе Хабра нет опции для подсветки кода Svelte-компонент. Поэтому svelte-код в этой статье будет "неподсвечен", однако в редакторе VS Code с этим все в порядке, имейте в виду.

src/components/Controller.svelte
<script lang="ts">
    import { createEventDispatcher } from "svelte";
  
    const dispatch = createEventDispatcher();
  
    const Action: { [key: string]: [string[], string] } = {
      left: [["ArrowLeft", "a", "A"], "Left ←, a, A"],
      right: [["ArrowRight", "d", "D"], "Right →, d, D"],
      down: [["ArrowDown", "s", "S"], "Down ↓, s, S"],
      turn: [["ArrowUp", "w", "W"], "Turn ↑, w, W"],
      drop: [[" "], "Drop [Space]"],
      pause: [["Pause", "p", "P"], "Pause [Pause]"],
    };
  
    function label(actionName: string) {
      const [_, hint] = Action[actionName];
      return hint;
    }
  
    function handleKeyDown(event: KeyboardEvent) {
      for (let action in Action) {
        const [codes, _] = Action[action];
        if (codes.includes(event.key)) {
          dispatch(action);
          break;
        }
      }
    }
  </script>
  
  <div>
    {#each Object.keys(Action) as actionName}
      <p>{label(actionName)}</p>
    {/each}
  </div>
  
  <svelte:window on:keydown={handleKeyDown} />
  
  <style>
    p {
      margin: 0px;
      font-size: 0.7rem;
    }
  </style>

Должно быть, вы обратили внимание на то, что код в функции handleKeyDown у нас так себе. Действительно, при всяком нажатии клавиши искать в массиве интересующее нас значение - затея уровня O(n). Обычно считается, что это не очень много, а в нашем случае, когда речь идет о поиске в массиве из нескольких элементов, осуществляемом максимум несколько раз в секунду - так и вполне допустимо. Однако, если вас подзадоривает внутренний перфекционист, то в этом отдельно взятом случае вы можете позволить себе дать слабину и улушчить этот код до уровня O(1). Благо, работы тут совсем немного. Предлагаю вам выполнить это улучшение в качестве упражнения.

Подсказка

Что-нибудь навроде этого. Но можно и компактнее, это уже сами, на свой вкус.

//...

const L = "Left ←, a, A";
const R = "Right →, d, D";
//...

const Actions = {
  'LeftArrow': L,
  'a': L,
  'A': L,
  'RightArrow': R,
  'd': R,
  'D': R,
  //...
}

function handleKeyDown(event: KeyboardEvent){
  if (event.key in Actions){
	dispatch(event.key, {});
  }
}

В коде Controller одна строчка кода стоит особняком, и, если вы выполнили мою рекомендацию, и, строго следуя моей рекомендации, прочитали только первые 8 глав руководства, она может показаться вам подозрительной. Вот эта строчка:

<svelte:window on:keydown={handleKeyDown} />

svelte:window - это специальный, "встроенный" компонент Svelte, один из девяти на момент написания статьи, предназначенный для удобной и единообразной обработки событий DOM-объекта window.

Компонент Stats

Для отображения достижений игрока в игре мы будем использовать компонент Stats.

src/components/Stats.svelte

<script lang="ts">
    import { Stats } from "../store/stats";
  </script>
  
  <div class="stats">
    <div class="item">Level: {$Stats.level}</div>
    <div class="item">Lines: {$Stats.lines}</div>
  </div>
  
  <style>
    .stats {
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
    }
    .item {
      font-size: 1rem;
    }
  </style>

Это совсем простой компонент, он получает информацию из хранилища, носящего такое же название Stats, и отображает текущие значения полей Stats.level и Stats.line. Заметьте, что хранилище Stats я мог назвать как угодно. В данном случае его название совпадает с названием компонента, который будет отображать данные из этого хранилища, но это вовсе не обязательное условие, можно было бы назвать как угодно.

Компонент Next

Компонент под названием Next выглядит немного посложнее. Его задача заключается в том, чтобы отрисовывать "подсказку" в виде фигурки, которая будет выдана следующей, после того, как текущая фигурка "прилипнет" ко дну Стакана или к застывшим "сталагмитам", образовавшимся в результате нагромождения ранее выпавших фигур.

src/components/Next.svelte

<script lang="ts">
    import type {Readable} from 'svelte/store';
    import { derived } from "svelte/store";
    import type { FigureView } from "../lib/entities";
    import { Next } from "../store/next";
    import { figureWidth, figureCellIsSolid } from "../lib/figures";
  
    const view: Readable<FigureView> = derived(Next, ($Next) => {
      const emptyLine = Array(figureWidth($Next) + 2).fill(0);
      return [
        emptyLine, 
        ...$Next.map((line) => [0, ...line, 0]), 
        emptyLine];
    });
  </script>
  
  <div>
    <table>
      {#each $view as line}
        <tr>
          {#each line as cell}
            {@const figure = figureCellIsSolid(cell)}
            <td class:figure />
          {/each}
        </tr>
      {/each}
    </table>
  </div>
  
  <style>
    div {
      margin: 0 auto;
    }
    td {
      width: 10px;
      height: 10px;
      background-color: #f5f5f5;
    }
    td.figure {
      background-color: lightgray;
    }
  </style>

Здесь стоит обратить внимание на то, что константа под названием view представляет собой "производное хранилище". То есть его содержимое вычисляется в зависимости от содержимого другого хранилища. Или сразу от множества хранилищ - по вашим потребностям, в зависимости от вашей ситуации.

Еще один интересный момент, на котором можно остановиться, в коде ниже:

{@const figure = figureCellIsSolid(cell)}
<td class:figure />

В первой строчке вычисляется значение константы figure, которую мы используем далее, во второй строчке. Если figure будет иметь значение true, то компилятор Svelte добавит тэгу td класс с именем "figure". Если же figure будет false, то, соответственно, добавления класса "figure" к тэгу td не произойдет.

Такое же поведение можно было бы получить и по-другому:

<td class:figure="{figureCellIsSolid(cell)}" />

Пожалуй, второй вариант более "правильный", но лично мне первый вариант кажется визуально "красивее", и более удобным для восприятия, что-ли. На самом деле, его использование более оправдано, если значение константы figure используется затем в нескольких местах, а не в одном, как у нас здесь.

Компонент Game

И, наконец, последний компонент в нашей игре - Game. Он будет отрисовывать Стакан со всем его содержимым, включая падающую фигурку. В коде этого компонента используются уже знакомые нам по предыдущим компонентам концепции, поэтому останавливаться на ее коде отдельно не стану.

src/components/Game.svelte
<script lang="ts">
    import { derived } from "svelte/store";
    import { BoxCellState } from "../lib/entities";
    import { Box } from "../store/box";
    import { Figure } from "../store/figure";
    import { figureCellIsSolid } from "../lib/figures";
    import { deepClone } from "../lib/utils";
  
    const view = derived([Box, Figure], ([$Box, $Figure]) => {
      const { view, location: { row, column } } = $Figure;
      let box = deepClone($Box);
      view.forEach((line, y) =>
        line.forEach((cell, x) => {
          if (figureCellIsSolid(cell)) {
            box[row + y][column + x] = BoxCellState.figure;
          }
        })
      );
      return box;
    });
  </script>
  
  <table>
    {#each $view as line}
      <tr>
        {#each line as cell}
          {@const empty = cell === BoxCellState.empty}
          {@const figure = cell === BoxCellState.figure}
          {@const frozen = cell === BoxCellState.frozen}
          <td class:empty class:figure class:frozen />
        {/each}
      </tr>
    {/each}
  </table>
  
  <style>
    td {
      width: 10px;
      height: 10px;
    }
    td.empty {
      background-color: #f5f5f5;
    }
    td.figure {
      background-color: lightgray;
    }
    td.frozen {
      background-color: gray;
    }
  </style>
  

Окончательный вид приложения

К этому моменту мы уже можем обрабатывать нажатия клавиш и отрисовывать текущее состояние игры, пришло время оживить игру - придать ей динамику. Основная игровая логика заключается в обычном typescript-файле src/game.ts. Заметьте, что ранее компоненты Svelte мы поместили в файлы с расширением .svelte. Но файл game.ts не является компонентом Svelte

src/game.ts
import { get } from 'svelte/store';
import {
    MaxHeartbeat,
    MinHeartbeat,
    MinLevel,
    MaxLevel,
    BoxWidth,
    BoxHeight,
    CompletedLinesToAdvanceLevel
} from './lib/settings';
import type { FigureView } from './lib/entities';
import { BoxCellState } from './lib/entities';
import {
    figureWidth,
    figureHeight,
    figureCellIsSolid,
    randomFigure
} from './lib/figures';
import { Box, clean as cleanBox } from './store/box';
import { Figure } from './store/figure';
import { Next } from './store/next';
import { Stats, reset as resetStats } from './store/stats';
import { turn90 } from './lib/linalg';
import { initialColumn } from './lib/utils';

let timer;
let heartbeat = MaxHeartbeat;

function gameOver() {
    stop();
    if (confirm('The game is over')) {
        cleanBox();
        resetStats();
        heartbeat = MaxHeartbeat;
        run();
    };
}

function figureAtTheBottom() {
    const box = get(Box);
    const { view, location: { row, column } } = get(Figure);
    const figureBottom = row + figureHeight(view);
    if (figureBottom === BoxHeight) {
        return true;
    }
    return view.some((line, j) =>
        line.some((cell, i) => {
            return figureCellIsSolid(cell)
                && (box[row + j + 1][column + i] === BoxCellState.frozen)
        }))
}

function freeze() {
    const box = get(Box);
    const { view, location: { row, column } } = get(Figure);
    view.forEach((line, j) => line.forEach((cell, i) => {
        if (figureCellIsSolid(cell)) {
            box[row + j][column + i] = BoxCellState.frozen;
        }
    }))
}

function increaseSpeed() {
    const oldHeartbeat = heartbeat;
    const levels = MaxLevel - MinLevel;
    const heartbeats = MaxHeartbeat - MinHeartbeat;
    const deltaHeartbeat = heartbeats / levels;
    const newHeartbeat = Math.floor(heartbeat - deltaHeartbeat);
    heartbeat = Math.max(MinHeartbeat, newHeartbeat);
    if (heartbeat > oldHeartbeat) {
        stop();
        run();
    }
}

function eraseCompletedLines() {
    const box = get(Box);

    const completedLines = [];
    box.forEach((line, j) => {
        if (line.every(cell =>
            cell === BoxCellState.frozen
        )) completedLines.push(j);
    })

    completedLines.forEach(completedLine => {
        for (let j = completedLine; j > 0; j--) {
            box[j] = [...box[j - 1]];
        }
    })

    const { level, lines } = get(Stats);
    const totalCompletedLines = lines + completedLines.length;
    const nextLevel = Math.min(MaxLevel,
        Math.floor(totalCompletedLines / CompletedLinesToAdvanceLevel));

    Stats.set({
        lines: totalCompletedLines,
        level: nextLevel
    });

    if (nextLevel > level) {
        increaseSpeed();
    }
}

function figureCrashesIntoFrozenLines(row: number, column: number, view: FigureView) {
    return view.some((line, j) =>
        line.some((cell, i) =>
            figureCellIsSolid(cell)
            && get(Box)[row + j][column + i] === BoxCellState.frozen));
}

function enabledPlacement(row: number, column: number, view: FigureView) {

    const figureInsideBox = (row >= 0)
        && ((row + figureHeight(view)) <= BoxHeight)
        && (column >= 0)
        && ((column + figureWidth(view)) <= BoxWidth);

    return figureInsideBox && !figureCrashesIntoFrozenLines(row, column, view)
}

function impossibleMovement(dRow: number, dColumn: number) {
    const { view, location: { row, column } } = get(Figure);
    return !enabledPlacement(row + dRow, column + dColumn, view);
}

function move(dRow: number, dColumn: number) {
    if (gameIsPaused()) return;
    if (impossibleMovement(dRow, dColumn)) return;
    let { view, location: { row, column } } = get(Figure);
    row += dRow;
    column += dColumn;
    Figure.set({ view, location: { row, column } });
}

function run() {
    timer = setInterval(() => {
        if (figureAtTheBottom()) {
            freeze();
            eraseCompletedLines();
            const next = get(Next);
            const row = 0;
            const column = initialColumn(next);
            if (figureCrashesIntoFrozenLines(row, column, next)) {
                gameOver();
            } else {
                Figure.set({ view: next, location: { row, column } });
                Next.set(randomFigure());
            }
        } else moveDown();
    }, heartbeat)
}
function stop() {
    clearInterval(timer);
    timer = undefined;
}

function moveLeft() { move(0, -1); }
function moveRight() { move(0, 1); }
function moveDown() { move(1, 0); }
function drop() {
    while (!figureAtTheBottom()) {
        moveDown();
    }
}
function turn() {
    if (gameIsPaused()) return;
    const { view, location: { row, column } } = get(Figure);
    const turned = turn90(view);
    if (enabledPlacement(row, column, turned)) {
        Figure.set({ view: turned, location: { row, column } });
    }
}

function gameIsPaused() {
    return timer === undefined;
}

function pause() {
    if (gameIsPaused()) {
        run();
    } else {
        stop();
    }
}

export const Tetris = {
    pause,
    moveLeft,
    moveRight,
    moveDown,
    drop,
    turn
}

Эта часть нашего приложения самая сложная, она содержит в себе игровую логику, однако Svelte-специфики в ней немного. Пожалуй, единственное, на что здесь стоит обратить особое внимание в контексте использования Svelte, это функция get(), импортированная из svelte/store. Ее назначение - получать значения хранилищ. Если в файле с расширением .svelte для получения значения некого хранилища с именем MyStore достаточно написать $MyStore - т.е. добавить значок "доллар" перед именем этого хранилища - то, чтобы получить значение хранилища в обычном javacript- или typescript-файле, нужно использовать либо функцию get(), либо промежуточные переменные, например, как-нибудь так:

import {MyStore} from './my-store'

let myStoreValue

MyStore.subscribe(value => myStoreValue = value)

...
console.log('Actual value of MyStore is ' + myStoreValue)

Согласитесь, визуально выглядит не очень, к тому же, при таком использовании имеются свои нюансы. Поэтому я предпочитаю использовать get(), но надо иметь в виду, что в документации Svelte рекомендуется не злоупотреблять использованием этой функции. Утверждается, что слишком частое ее использование ведет к падению производительности, но я не помню, чтобы лично я когда-то пострадал из-за этого.

Точка сбора приложения - файл App.svelte

Теперь у нас все готово для того, чтобы собрать все части приложения в единое целое. Точкой сбора приложения служит файл App.svelte. Эта "точка сбора" задается в файле src/main.ts.

src/main.ts
import App from './App.svelte'

const app = new App({
  target: document.getElementById('app')
})

export default app

src/App.svelte

<script lang="ts">
  import { Tetris } from "./game";
  import Game from "./components/Game.svelte";
  import Next from "./components/Next.svelte";
  import Stats from "./components/Stats.svelte";
  import Controller from "./components/Controller.svelte";
</script>

<main>
  <div>
    <Game />
  </div>
  <div class="info">
    <Next />
    <Controller
      on:left={() => Tetris.moveLeft()}
      on:right={() => Tetris.moveRight()}
      on:down={() => Tetris.moveDown()}
      on:drop={() => Tetris.drop()}
      on:turn={() => Tetris.turn()}
      on:pause={() => Tetris.pause()}
    />
    <Stats />
  </div>
</main>

<style>
  main {
    display: flex;
    flex-direction: row;
    padding-top: 1rem;
    justify-content: center;
  }
  .info {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    padding: 1rem;
  }
</style>

Файл App.svelte выступает как бы связующим звеном нашего приложения. В нем немного кода, и, глядя на код, вы сможете без труда разобраться, что тут происходит. Теперь можно открыть консоль и запустить приложение командой

npm run dev

Напомню, что при запуске приложения оно находится у нас на паузе, такое поведение мы задали в файле game.ts. Поэтому, чтобы игра "ожила", от пользователя потребуется установить фокус на окне браузера с запущенным приложением, для чего потребуется кликнуть мышкой по окну, и нажать кнопку "Пауза". Через пару секунд фигурка оживет и начнет свое движение ко дну Стакана. Если вам кажется, что фигура падает слишком медленно, вы может установить свою скорость падения в файле settings.ts в значении переменной MaxHeartbeat, которая как раз таки и устанавливает начальное значение длительности паузы между смещениями фигурки вниз.

Вместо заключения

Игра получилась довольно аскетичная, чего уж там. Но у нее имеются проблемы не только с внешним видом. Более опытные программисты, наверное, уже заметили, что все наши хранилища доступны для записи/изменения в любом месте приложения, и, по сути, являются глобальными переменными, хотя в этом нет острой потребности. С другой стороны, хранилища играют здесь роль шины передачи данных, а вынос их в отдельные файлы/сущности улучшает, на мой взгляд, читаемость. Как бы то ни было, "глобальность без необходимости" в данном случае это не страшно еще и потому, что "бизнес-логика" нашего приложения очень простая, и не подразумевает дальнейшего развития проекта. Однако, если речь идет о более серьезных приложениях, то это может быть проблемой, особенно если над проектом идет работу "вдолгую", да еще и коллективно. В этом случае рано или поздно у кого-нибудь может появиться соблазн записывать в хранилища где-нибудь еще, и, скорее всего, рано или поздно этому соблазну будет дан ход. В общем, не стоит делать сущности открытыми для модификации без острой на то необходимости, ежели это требование изначально не было заложено в архитектуру проекта. В нашем случае такой необходимости нет, и поэтому можно было бы организовать более "правильную" работу с хранилищами. Например, внести хранилища в game.ts, и экспортировать их только для чтения, чтобы компоненты, которые "завязаны" на них, могли бы использовать их значения. Но, все-таки, надо смотреть в каждом конкретном случае, как будет "лучше", а эта ремарка здесь скорее для того, чтобы показать, что так тоже можно.

// game.ts
const next = writable(randomFigure())
...

export const Next = { subscribe: next.subscribe }

Заметьте, что я экспортировал только один единственный метод subscribe хранилища next, которое находится в локальной области видимости файла game.ts, и, на самом деле, этого достаточно для того, чтобы можно было использовать Next как хранилище "только для чтения" и иметь его актуальное значение в любой момент в любом Svelte-компоненте, который его использует, с использованием значка доллара:

import {Next} from './game.ts';
...
<p> Actual value of Next is {$Next} </p>

На самом деле, в этом проекте мы могли бы обойтись вообще без хранилищ, используя только события компонент. В качестве упражнения, при наличии времени и желания, можете попробовать избавиться от хранилищ и построить работу с опорой на события. И, вполне возможно, что у вас получится более "правильное" и красивое приложение с архитектурной точки зрения.

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


  1. romkarx
    01.06.2023 12:52
    +1

    Что касается русской документации, то для Svelte есть - https://sveltedocs.ru
    а для SvelteKit есть - https://svelte-kit.ru/ (в процессе перевода)