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

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

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

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

Разработка

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

Найдется немало поклонников всевозможных 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), которая в настоящее время является руководством по ротации в Тетрисе. Она определяет, где и как появляются фигуры и, самое главное, как они вращаются.

Стандартная система ротации. Изображение из Тетрис 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. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

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


  1. Moog_Prodigy
    01.01.2024 17:07
    +5

    Николай Николаевич первым в отделе освоил шестнадцатеричную систему исчисления и нашел ей практическое применение.
    Случилось это так.
    Работал вместе с Николаем Николаевичем его коллега и приятель Федорыч, бывший военнослужащий ВДВ. Как следствие своего десантного прошлого, Федорыч не отличался глубоким и тонким интеллектом, однако обладал развитыми рефлексами и быстротой движений, столь необходимыми при игре в Тетрис. Достаточно быстро Федорыч вытеснил Николая Николаевича из Топ 10 результатов Тетриса, и Николай Николаевич понял, что соперничать в Тетрисе с недалеким, но ловким и ухватистым Федорычем ему никак невозможно. Несколько недель Федорыч заслуженно почивал на лаврах, снисходительно поглядывая на беспомощные попытки остальных проектантов войти в Топ 10 хотя бы на 10 месте. Если вдруг кому-то ценой титанических усилий удавалось показать подобный результат, Федорыч в следующем же своем подходе к снаряду с легкостью выкидывал дерзкого выскочку из десятки. Казалось, эра господства Федорыча будет длиться вечно. Один только Николай Николаевич не опустил рук. По книге Брябрина он изучил принципы шестнадцатеричного исчисления, а затем освоил ввод шестнадцатеричных символов во встроенном редакторе Norton Commander'а с помощью альтовых кодов. По тем временам это было сокровенное знание, доступное лишь избранной когорте посвященных.
    Дальше все было просто. Обращаюсь к ветеранам компьютеризации: если помните, в ортодоксальной версии Тетриса Алексея Пажитнова результаты элементарно записывались в текстовый файл в шестнадцатеричном виде. Как только Николай Николаевич сопоставил эти факты с полученным им сокровенным знанием, последующая беспокойная судьба Федорыча была предрешена.
    Придя в очередной раз на работу и присев размяться в Тетрис, Федорыч с необычайным удивлением обнаружил во главе хит-парада запись Н.Н.Данильченко с результатом, превышающим предыдущий сразу на тысячу. (Николай Николаевич решил не мелочиться при редактировании файла tetris.res.) Легковерный Федорыч принял результат за чистую монету, матюгнулся пару раз и, засучив рукава, пустился в погоню за лидером. Он примерно неделю никого не подпускал к компу, глаза его приобрели хроническую красноту и слезились, суставы пальцев болезненно распухли, но в итоге он доказал, что в ВДВ люди не лаптем щи хлебают. Фантастический результат был перекрыт.
    Горжусь тобой, Федорыч! Можешь! восхищался им Николай Николаевич.
    Он дал Федорычу несколько дней на то, чтобы тот до дна испил чашу утоленного тщеславия, а затем вновь взялся за текстовый редактор. И на следующее утро поверженный Федорыч вновь, матюгаясь, развивал свои рефлексы под градом падавших угловатых четырехзвенных фигурок.
    Со временем процесс этот приобрел циклический характер. Приблизительно раз в неделю Николай Николаевич тайно редактировал tetris.res, вписывая себя на первое место, и все оставшиеся дни погруженный в Тетрис Федорыч осатанело колотил по курсорным клавишам, пытаясь противопоставить силу человеческой мысли беспределу генератора случайных чисел.
    Музыка, Артем Викторович, просто музыка... говаривал мне Николай Николаевич, блаженно прислушиваясь к пулеметной скорострельности Федорыча, насиловавшего XT-шку за перегородкой. Вот, Артем Викторович, слышишь дрессирую Федорыча, вывожу его на запредельный результат... И, обрати внимание, на принципах полной добровольности. Сам парень рвется в бой, старается... Метод Дурова-Данильченко!
    Тут слышался сокрушительный удар кулаком по столу с криком Ссука!!!, испуганные визги теток и грохот подпрыгнувшей клавиатуры.
    Опять Федорыча завалило... озабоченно констатировал Николай Николаевич, Думал я было вписаться завтра в файл, но, чувствую, рановато пока...


  1. NutsUnderline
    01.01.2024 17:07
    +2

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


  1. CorwinH
    01.01.2024 17:07

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