От лица beeline cloud поздравляем всех читателей Хабра с Новым годом! Подготовили для вас статью про необычный подарок. Будем рады, если вы в комментариях поделитесь своими историями и расскажете, какие интересные технологичные презенты вам доводилось дарить или получать в канун Нового года!
До Рождества оставалось несколько недель, а я никак не мог определиться с выбором подарка для сестры. Её неожиданный вопрос, — существует ли приложение «Тетрис» без отслеживания и рекламы, — натолкнул меня на прекрасную идею — преподнести ей на Рождество свой вариант этой игры.
Я был уверен, что смогу разработать приложение до праздников. Реализация всем известной игры оказалась довольно простой. Несмотря на это, она превратилась в веселое соревнование для всей семьи, в котором можно было побороться за первое место в таблице лидеров.
Разработка
В разработке приложений я предпочитаю использовать либо прогрессивные веб-приложения (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), которая в настоящее время является руководством по ротации в Тетрисе. Она определяет, где и как появляются фигуры и, самое главное, как они вращаются.
Самым большим камнем преткновения стал вопрос о том, как отобразить линии на карте и падающие фрагменты. Для карты я использовал массив с -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. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.