Вскрытие о разработке игры Tetris в качестве рождественского подарка

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

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

Технологии

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

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

Геймплей

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

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

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), которая является текущим руководством Tetris для ротаций. Он определяет, где и как появляются части и, самое главное, как они вращаются.

Когда дело дошло до реализации, самый большой вопрос заключался в том, как представить линии на карте и падающие части. Для карты я использовал массив с -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 ];

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

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 расположена вертикально и находится непосредственно рядом со стеной, она не может нормально вращаться, потому что ее части окажутся за пределами игровой площадки. Вместо этого деталь должна перемещаться в сторону при вращении, если это положение не затруднено. Это известно как удар по стене. Кроме того, есть и другие специальные приемы, которые включают вращение фигуры в ограниченном пространстве, самый известный пример — T-Spin. Тем не менее, я не реализовал T-Spins в первой версии, потому что казалось, что есть много особых случаев.

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

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 нет встроенной поддержки для распознавания сенсорных жестов, поэтому мне пришлось реализовать логику для этого самому. Смахивание влево и вправо перемещает фигуру, а обычное касание вращает ее. Однако остались еще две функции, которым нужно было управлять: мягкое и жесткое падение. Хотя у нас по-прежнему осталось два направления смахивания (вверх и вниз), смахивание вверх для перемещения чего-либо вниз казалось крайне нелогичным, поэтому я в конечном итоге использовал свайп вниз для резких движений. Это означает, что с помощью сенсорного управления нельзя мягко сбросить предмет. Тем не менее, это то, что вам не нужно очень часто, и если это необходимо, можно немного подождать, пока он не упадет сам по себе. Это немного раздражает на более низких скоростях, но в целом не слишком большая проблема.

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

Наконец, в игру также можно играть с помощью мыши, поскольку сенсорное управление также работает с мышами.

Графика

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

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

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

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

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

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

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

Рекорд

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

Юридический

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

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

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

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