История создания игры “Тетрис”: идеальный подарок на Новый год или Рождество

От лица beeline cloud поздравляем всех читателей Хабра с Новым годом! Подготовили для вас статью про необычный подарок. Будем рады, если вы в комментариях поделитесь своими историями и расскажете, какие интересные технологичные презенты вам доводилось дарить или получать в канун Нового года! 

До Рождества оставалось несколько недель, а я никак не мог определиться с выбором подарка для сестры. Её неожиданный вопрос, — существует ли приложение «Тетрис» без отслеживания и рекламы, — натолкнул меня на прекрасную идею — преподнести ей на Рождество свой вариант этой игры.

Я был уверен, что смогу разработать приложение до праздников. Реализация всем известной игры оказалась довольно простой. Несмотря на это, она превратилась в веселое соревнование для всей семьи, в котором можно было побороться за первое место в таблице лидеров.

Unsplash, Ali Yılmaz
Unsplash, Ali Yılmaz

Разработка

В разработке приложений я предпочитаю использовать либо прогрессивные веб-приложения (PWA), либо упаковывать веб-приложение в нативное с помощью того же Capacitor. Для этой игры я выбрал PWA. Работает на любой платформе (даже на iOS) и почти не требует какой-либо настройки. К тому же под рукой был шаблон для проектов PWA, что еще больше упростило работу. Автономный режим был основной функцией PWA, необходимой для данного приложения. Все файлы кэшируются, поэтому в игру можно играть без подключения к сети.

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

Геймплей

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

За основу взял игровую область 10×20 и очень много времени потратил на написание функции для изменения размера холста. Необходимо было поддерживать правильное соотношение сторон, используя при этом как можно больше места на экране.

function resizeCanvas()
{
    var windowWidth = window.innerWidth;
    var windowHeight = window.innerHeight;
    
    // the playing area is half as wide as it is tall
    var newWidth = Math.floor( windowHeight/2 );
    
    // if the window is more than two times taller than wide, use the full width and leave a bit of space at the bottom
    if ( windowHeight > windowWidth*2 )
    {
        newWidth = windowWidth;
    }
    
    this.tileSize = Math.floor( newWidth/10 );
    
    // resize the drawing surface
    this.canvas.width = Math.floor( this.tileSize * 10 * devicePixelRatio );
    this.canvas.height = Math.floor( this.tileSize * 20 * devicePixelRatio );
    
    // resize the canvas element
    this.canvas.style.width = this.tileSize*10+"px";
    this.canvas.style.height = this.tileSize*20+"px";
    
    // save the canvas dimensions
    this.canvasWidth = this.tileSize*10;
    this.canvasHeight = this.tileSize*20;
    
    // scale the drawing surface ( important for high resolution screens )
    this.ctx.scale( devicePixelRatio , devicePixelRatio );
}

Изучая различные правила, я наткнулся на стандартную систему ротации (SRS), которая в настоящее время является руководством по ротации в Тетрисе. Она определяет, где и как появляются фигуры и, самое главное, как они вращаются.

Стандартная система ротации. Изображение из Тетрис Wiki.
Стандартная система ротации. Изображение из Тетрис Wiki.

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

this.map = new Array( this.width * this.height );
this.map.fill( -1 );

Долго пришлось ломать голову над тем, как правильно представить падающие фигуры, и в итоге я жестко закодировал 4 состояния вращения для всех 7 фигур. Хотя жесткое кодирование чего-либо обычно не приветствуется, это оказалось отличным решением, прекрасно подходящим для этой игры.

const L_state1 = [
0, 0, 1,
1, 1, 1,
0, 0, 0
];
const L_state2 = [
0, 1, 0,
0, 1, 0,
0, 1, 1
];
const L_state3 = [
0, 0, 0,
1, 1, 1,
1, 0, 0
];
const L_state4 = [
1, 1, 0,
0, 1, 0,
0, 1, 0
];
const L_piece = [ L_state1 , L_state2 , L_state3 , L_state4 ];

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

Piece.prototype.isValidPosition = function( state , xIn , yIn )
{
    for ( let y = 0 ; y < 3 ; ++y )
    {
        for ( let x = 0 ; x < 3 ; ++x )
        {
            let xTile = xIn + x; 
            let yTile = yIn + y;
            if ( this.data[state][y*3+x] == 1 )
            {
                // check if out of bounds left or right
                if ( xTile < 0 || xTile >= this.tetris.width )
                {
                    return false;
                }
                // check if at the bottom
                if ( yTile >= this.tetris.height )
                {
                    return false;
                } 
                // check if all map tiles are free
                if ( this.tetris.map[yTile*this.tetris.width+xTile] != -1 )
                {
                    return false;
                }
            }
        }
    }
    return true;
}

Легко просчитывается, может ли фигура двигаться вправо или в любом другом направлении. Если для неё действительна позиция x+1, то значение x увеличивается.

Piece.prototype.canMoveRight = function()
{
    return this.isValidPosition( this.stateCounter , this.x+1 , this.y );
}

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

var nextRotationState = this.stateCounter + 1;
if ( nextRotationState == 4 ) nextRotationState = 0;

if ( this.isValidPosition( nextRotationState , this.x , this.y ) )
{
    this.stateCounter++;
    if ( this.stateCounter == 4 ) this.stateCounter = 0;
}

Однако существуют и особые случаи ротации. Например, если I-образная фигура вертикальна и находится в непосредственной близости со стенкой, она не может нормально вращаться, поскольку часть её окажется за пределами игровой зоны. При вращении фигуру придётся сдвинуть в сторону, если эта позиция не перекрыта. Кроме того, существуют и другие специальные перемещения, предполагающие вращение фигуры в ограниченном пространстве. Из них наиболее известно Т-образное вращение. Однако я не стал его реализовывать в первой версии приложения, решив, что имеющихся сложных игровых моментов пока достаточно.

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

for ( let y = 0 ; y < 3 ; ++y )
{
    for ( let x = 0 ; x < 3 ; ++x )
    {
        if ( this.data[this.stateCounter][y*3+x] == 1 )
        {
            ctx.drawImage( image , (xPos+x)*tileSize , (yPos+y)*tileSize , tileSize , tileSize );
        }
    }
}

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

// check for full lines
var linesCleared = 0;
for ( let y = 0 ; y < this.height ; ++y )
{
    var lineFilledCounter = 0;
    for ( let x = 0 ; x < this.width ; ++x )
    {
        if ( this.map[y*this.width+x] != -1 )
        {
            ++lineFilledCounter;
        }
    }
    if ( lineFilledCounter == this.width )
    {
        // clear the line
        for ( let x = 0 ; x < this.width ; ++x )
        {
            this.map[y*this.width+x] = -1;
        }
        // copy everything else down
        for ( let yInner = y-1 ; yInner >= 0 ; --yInner )
        {
            for ( let xInner = 0 ; xInner < this.width ; ++xInner )
            {
                this.map[(yInner+1)*this.width+xInner] = this.map[yInner*this.width+xInner];
            }
        }
        ++linesCleared;
    }
}

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

Управление

Выше я упоминал, что игра реализована как PWA. Независимость платформы предполагала необходимость поддержки различных схем управления. Из них сенсорное было приоритетным, так как моя сестра, скорее всего, предпочтёт именно его. Однако сам я привык использовать клавиатуру.

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

Однако с сенсорным управлением всё несколько сложнее. Насколько мне известно, встроенной поддержки для распознавания сенсорных жестов в JavaScript не существует, поэтому пришлось разрабатывать такую логику самостоятельно. Проведение пальцем влево и вправо перемещает деталь, а обычное прикосновение её вращает. С этим всё понятно. Однако оставались еще две функции, требующие контроля —  мягкое и жесткое падение. В запасе имелось два направления смахивания (вверх и вниз), но смахивание вверх для перемещения чего-либо вниз казалось крайне нелогичным. В итоге смахивание вниз я использовал для резких падений. Таким образом пришлось отказаться от мягкого сброса фигуры с помощью сенсорного управления. Однако это требуется не так уж и часто, а при необходимости можно немного подождать, пока фигура упадёт сама. На низких скоростях такое немного раздражает, но в целом не является очень уж большой проблемой.

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

Графика

Создание художественных объектов обычно занимает много времени при разработке игры. К счастью, Тетрис не требует утончённого художественного подхода.

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

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

В качестве украшения я добавил зимнюю рождественскую тему в виде падающего снега в главном меню.

Звуковые эффекты

Самой большой проблемой при создании игры были звуковые эффекты (впрочем, как и всегда). Я потратил много времени, пытаясь создать звуки при помощи jfxr или записать что-нибудь, используя микрофон. Однако все мои попытки приводили лишь к разочарованию, невзирая на множество настроек в Audacity. Понравился только звуковой эффект при перемещении фрагмента влево и вправо. Это была отредактированная версия записи нажатия клавиш механической клавиатуры. Остальная же озвучка оставляла желать лучшего.

Никакой музыки для игры я не писал. Любая другая, отличающаяся от оригинальной темы «Тетриса», показалась бы неправильной. К тому же я не хотел использовать что-либо, защищенное авторскими правами.

Таблица рекордов

Возможно, лучшим моим решением было включить в игру рейтинг игроков. Уже через несколько дней после Рождества игра превратилась в жесткое внутрисемейное соревнование за первое место в турнирной таблице. Во время тестирования программы я набирал максимум около 70 000 баллов. Никогда не думал, что у кого-то получится набрать больше 100 000 очков. Однако на момент написания этой статьи моя сестра занимает первое место с результатом 294 636. У меня же, несмотря на статус разработчика, на данный момент самый низкий результат (хотя и меньше всего попыток). Вероятно, создание игр удаётся мне лучше, чем соревнования в них.

Таблица лидеров на момент написания статьи. Изображение автора.
Таблица лидеров на момент написания статьи. Изображение автора.

Правовая информация

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

Что может быть приятней, чем хорошо принятый подарок? Судя по тому, сколько часов члены моей семьи (включая меня) уже провели в этой игре, возможно, это был один из моих лучших рождественских сюрпризов.

Да и сам процесс создания приложения также доставил мне массу удовольствия. Разве его можно сравнить с нудным поиском на Amazon, возможно, совершенно бесполезной вещи?

Моё творение, конечно, не претендует на звание лучшего Тетриса всех времён и народов. Но зато игра не отслеживает своих пользователей, работает на большинстве платформ, имеет офлайн-режим и в неё просто очень весело играть.

beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

 

Источник

god, игры, идеальный, или, история, на, Новый, подарок, рождество, создания, тетрис

Читайте также