Собирать цветные камни – лучший способ убить время. Заглянем под капот игры Bejeweled и создадим ее с нуля с помощью jQuery. Присоединяйтесь!
Инструменты
Что нам понадобится для создания игры?
- библиотека jQuery 3 версии – для более легкой работы с DOM-деревом;
- плагин jquery.touchSwipe.js – для удобной обработки пользовательских событий;
- начальные знания HTML, CSS и JavaScript основы;
- любовь к казуальным играм.
Безусловно, можно обойтись без библиотек и плагинов. Язык JavaScript имеет достаточно возможностей для работы со страницей и пользовательскими событиями. Но если вы начинающий разработчик, эти инструменты существенно упростят вам жизнь.
Начало работы
Создаем файл index.html
, который будет представлять нашу игру. Подключаем все необходимые скрипты – их всего три:
- сама библиотека jQuery (возьмем ее с CDN);
- скрипт плагина, который должен лежать в той же папке (можно загрузить из официального репозитория);
- скрипт игры
bejeweled.js
, который нам и предстоит написать (этот файл лежит в той же папке и пока пуст).
<!DOCTYPE html> <html> <head> <script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script> <script src ="jquery.touchSwipe.js"></script> <script src ="bejeweled.js"></script> </head> <body> </body> </html>
Состояния игры
Сразу определимся, в каких состояниях (стадиях) может находиться игровой процесс:
pick
– ожидаем, когда пользователь выберет гем (первый или второй рядом с первым);switch
– после выбора второго гема меняем их местами;revert
– после свитча не появилось групп сбора, возвращаемся к исходному состоянию (меняем позиции гемов обратно);remove
– после свитча появились группы сбора, которые нужно удалить;refill
– после удаления групп сбора нужно заполнить образовавшиеся пустоты новыми гемами.
Игровое поле
Поле для игры в Bejeweled представляет собой простую двумерную сетку, на которой располагаются гемы (драгоценные камни). Их необходимо удалять, собирая в группы одного цвета.
В нашей реализации для простоты будут учитываться только вертикальные или горизонтальные группы размером от трех камней (и более).
Примеры допустимых групп:
Первая попытка
Давайте определим основные параметры игры, нарисуем поле и заполним его гемами.
// Создание и заполнение поля $(document).ready(function(){ /* настройки */ let gemSize = 64; // размер гема let gemClass = "gem"; // класс элементов-гемов let gemIdPrefix = "gem"; // префикс для идентификаторов let numRows = 6; // количество рядов let numCols = 7; // количество колонок let jewels = new Array(); // двумерный массив гемов на поле let gameState = "pick"; // текущее состояние поля - ожидание выбора гема /* цвета гемов */ let bgColors = new Array( "magenta", "mediumblue", "yellow", "lime", "cyan", "orange", "crimson", "gray" ); /* создание поля */ $("body") .append('<div id = "gamefield"></div>') .css({ "background-color": "black", "margin": "0" }); $("#gamefield") .css({ "background-color": "#000000", "position": "relative", "width": (numCols * gemSize) + "px", "height": (numRows * gemSize) + "px" }); /* создание сетки поля */ for(i = 0; i < numRows; i++){ jewels[i] = new Array(); for(j = 0; j < numCols; j++){ jewels[i][j] = -1; } } /* генерация исходного набора гемов */ for(i = 0; i < numRows; i++){ for(j = 0; j < numCols; j++){ /* Cоздать гем со случайным цветом записать его в сетку jewels, создать DOM-элемент, раскрасить его и поместить его на поле */ jewels[i][j] = Math.floor(Math.random() * 8); $("#gamefield").append('<div class = "' + gemClass + '" id = "' + gemIdPrefix + '_' + i + '_' + j + '"></div>'); $("#" + gemIdPrefix + "_" + i + "_" + j).css({ "top": (i * gemSize) + 4 + "px", "left": (j * gemSize) + 4 + "px", "width": "54px", "height":"54px", "position": "absolute", "border": "1px solid white", "cursor": "pointer", "background-color": bgColors[jewels[i][j]] }); } } });
- Мы создали поле размером 7х6 ячеек и представляющий его массив
jewels
. - Для начала заполнили его значениями
-1
– отсутствие гема. - Затем прошлись циклом по каждой ячейке, выбрали случайным образом цвет и записали его в
jewels
. Также создали DOM-элемент, который представляет гем на странице.
Есть проблема: на поле еще до начала игры появляются группы сбора (вертикальные и горизонтальные группы из 3 и более гемов одного цвета). Это происходит из-за того, что цвета мы выбираем случайным образом.
Проверка на группы сбора
Добавим проверку после выбора цвета для нового гема. Если он вдруг составит группу сбора с уже существующими на поле камнями, просто поменяем цвет.
/* генерация исходного набора гемов */ for(i = 0; i < numRows; i++){ for(j = 0; j < numCols; j++){ /* если вновь созданная фишка составляет группу сбора с уже имеющимися, заменить ее на новую */ do{ jewels[i][j] = Math.floor(Math.random() * 8); } while(isStreak(i, j)); $("#gamefield").append('<div class = "' + gemClass + '" id = "' + gemIdPrefix + '_' + i + '_' + j + '"></div>'); $("#" + gemIdPrefix + "_" + i + "_" + j).css({ "top": (i * gemSize) + 4 + "px", "left": (j * gemSize) + 4 + "px", "width": "54px", "height":"54px", "position": "absolute", "border": "1px solid white", "cursor": "pointer", "background-color": bgColors[jewels[i][j]] }); } } /* проверка на группы сбора */ function isVerticalStreak(row, col){ let gemValue = jewels[row][col]; let streak = 0; let tmp = row; while(tmp > 0 && jewels[tmp - 1][col] == gemValue){ streak++; tmp--; } tmp = row; while(tmp < numRows - 1 && jewels[tmp + 1][col] == gemValue){ streak++; tmp++; } return streak > 1 } function isHorizontalStreak(row, col){ let gemValue = jewels[row][col]; let streak = 0; let tmp = col; while(tmp > 0 && jewels[row][tmp - 1] == gemValue){ streak++; tmp--; } tmp = col; while(tmp < numCols - 1 && jewels[row][tmp + 1] == gemValue){ streak++; tmp++; } return streak > 1 } function isStreak(row, col){ return isVerticalStreak(row, col) || isHorizontalStreak(row, col); }
Функция isVerticalStreak
проверяет соседей по вертикали, а isHorizontalStreak
– соответственно по горизонтали. Если где-то получится больше двух гемов одного цвета, создадим другой гем.
Обработка действий игрока
Чтобы наша Bejeweled заработала, нужно отслеживать, какие гемы выбирает игрок, менять их местами и убирать появившиеся группы.
Язык программирования JavaScript умеет работать со множеством пользовательских событий – кликами, тачами, движениями мыши и др. Организовать все это в единую рабочую (а также кроссбраузерную и кроссплатформенную!) систему очень непросто. Так давайте отдадим тяжелую и скучную работу плагину touchSwipe.
/* отслеживание действий игрока */ $("#gamefield").swipe({ tap: tapHandler }); function tapHandler(event, target) { console.log('tap', target); }
Начнем с простых тапов/кликов. Попробуйте кликнуть мышкой на поле – в консоли появится сообщение и выведется тот гем, на который вы нажали.
Игровые операции
Выделение активного гема
Чтобы было понятно, какой камень выбрал игрок, нужно его каким-то образом выделять. Для этого добавим элемент-маркер на поле:
/* добавляем маркер */ $("body").append('<div id = "marker"></div>'); $("#marker").css({ "width": (gemSize - 10) + "px", "height": (gemSize - 10) + "px", "border": "5px solid white", "position": "absolute" }).hide();
А при клике на гем, поместим маркер прямо над ним:
function tapHandler(event, target) { /* клик по гему */ if($(target).hasClass("gem")){ /* ожидается выбор гема */ if(gameState == "pick"){ // определить строку и столбец let row = parseInt($(target).attr("id").split("_")[1]); let col = parseInt($(target).attr("id").split("_")[2]); // выделить гем маркером $("#marker").show(); $("#marker").css("top", row * gemSize).css("left", col * gemSize); } } }
Меняем гемы местами
Если игрок выбирает первый гем из двух, мы только сохраним его позицию в переменных selectedRow
и selectedCol
.
А вот если выбран уже второй гем, нужно проверить, является ли он соседом для первого.
let selectedRow = -1; // выбранный ряд let selectedCol = -1; // выбранный столбец let posX; // столбец второго выбранного гема let posY; // ряд второго выбранного гема function tapHandler(event, target) { /* клик по гему */ if($(target).hasClass("gem")){ /* ожидается выбор гема */ if(gameState == "pick"){ // определить строку и столбец let row = parseInt($(target).attr("id").split("_")[1]); let col = parseInt($(target).attr("id").split("_")[2]); // выделить гем маркером $("#marker").show(); $("#marker").css("top", row * gemSize).css("left", col * gemSize); // если ни один гем не выбран, сохранить позицию выбранного if(selectedRow == -1){ selectedRow = row; selectedCol = col; } else { /* если какой-то гем уже выбран, проверить, что тап был по соседнему гему и поменять их местами иначе просто выделить новый гем */ if( (Math.abs(selectedRow - row) == 1 && selectedCol == col) || (Math.abs(selectedCol - col) == 1 && selectedRow == row) ){ $("#marker").hide(); // переключить состояние игры gameState = "switch"; // сохранить позицию второго выбранного гема posX = col; posY = row; // поменять их местами gemSwitch(); } else{ selectedRow = row; selectedCol = col; } } } } }
Функция gemSwitch
меняет местами два гема как на поле, так и в массиве jewels
:
function gemSwitch(){ let yOffset = selectedRow - posY; let xOffset = selectedCol - posX; $("#" + gemIdPrefix + "_" + selectedRow + "_" + selectedCol) .addClass("switch") .attr("dir", "-1"); $("#" + gemIdPrefix + "_" + posY + "_" + posX) .addClass("switch") .attr("dir", "1"); // анимировать свитч $.each($(".switch"),function(){ movingItems++; $(this).animate({ left: "+=" + xOffset * gemSize * $(this).attr("dir"), top: "+=" + yOffset * gemSize * $(this).attr("dir") },{ duration: 250, complete: function() { // после завершения анимации, проверить, доступен ли такой ход checkMoving(); } }).removeClass("switch") }); // поменять идентификаторы гемов $("#" + gemIdPrefix + "_" + selectedRow + "_" + selectedCol) .attr("id", "temp"); $("#" + gemIdPrefix + "_" + posY + "_" + posX) .attr("id", gemIdPrefix + "_" + selectedRow + "_" + selectedCol); $("#temp") .attr("id", gemIdPrefix + "_" + posY + "_" + posX); // поменять гемы в сетке let temp = jewels[selectedRow][selectedCol]; jewels[selectedRow][selectedCol] = jewels[posY][posX]; jewels[posY][posX] = temp; }
Вводим еще одну функцию – checkMoving
. Она будет проверять, появились ли на поле группы сбора после свитча.
Проверка хода
Переменная movingItems
хранит количество движущихся в данный момент гемов. Функция checkMoving
вызывается после завершения каждой анимации, но отработать должна только тогда, когда все камни остановятся. Для этого мы просто каждый раз уменьшаем счетчик на единицу.
let movingItems = 0; // количество передвигаемых в данный момент гемов
checkMoving
– главная функция в нашей головоломке. Она будет проверять поле после каждого действия, поэтому мы добавим в нее конструкцию switch
для разных состояний игры:
function checkMoving() { movingItems--; // когда закончилась анимация последнего гема if(movingItems == 0) { // действуем в зависимости от состояния игры switch(gameState) { // после передвижения гемов проверяем поле на появление групп сбора case "switch": case "revert": // проверяем, появились ли группы сбора if(!isStreak(selectedRow, selectedCol) && !isStreak(posY, posX)) { // если групп сбора нет, нужно отменить совершенное движение // а если действие уже отменяется, то вернуться к исходному состоянию ожидания выбора if(gameState != "revert"){ gameState = "revert"; gemSwitch(); } else{ gameState = "pick"; selectedRow = -1; } } else { // если группы сбора есть, нужно их удалить gameState = "remove"; // сначала отметим все удаляемые гемы if(isStreak(selectedRow, selectedCol)){ removeGems(selectedRow, selectedCol); } if(isStreak(posY, posX)){ removeGems(posY, posX); } // а затем уберем их с поля gemFade(); } break; } } }
Если ни один из выбранных камней не образовал группу сбора, меняем состояние игры на revert
и двигаем их обратно.
После реверта мы снова попадем в эту функцию. В данном случае просто сбрасываем выбор и устанавливаем gameState = "pick"
.
Если группы сбора обнаружены, их нужно удалить.
Удаление групп сбора
Гемы из группы сбора нужно убрать с поля и из массива jewels
. Сначала пометим их DOM-элементы классом remove
:
/* помечаем удаляемые гемы классом remove и убираем их из сетки */ function removeGems(row, col) { let gemValue = jewels[row][col]; let tmp = row; $("#" + gemIdPrefix + "_" + row + "_" + col).addClass("remove"); if(isVerticalStreak(row, col)){ while(tmp > 0 && jewels[tmp - 1][col] == gemValue){ $("#" + gemIdPrefix + "_" + (tmp - 1) + "_" + col).addClass("remove"); jewels[tmp - 1][col] = -1; tmp--; } tmp = row; while(tmp < numRows - 1 && jewels[tmp + 1][col] == gemValue){ $("#" + gemIdPrefix + "_" + (tmp + 1) + "_" + col).addClass("remove"); jewels[tmp + 1][col] = -1; tmp++; } } if(isHorizontalStreak(row, col)){ tmp = col; while(tmp > 0 && jewels[row][tmp - 1]==gemValue){ $("#" + gemIdPrefix + "_" + row + "_" + (tmp - 1)).addClass("remove"); jewels[row][tmp - 1] = -1; tmp--; } tmp = col; while(tmp < numCols - 1 && jewels[row][tmp + 1]==gemValue){ $("#" + gemIdPrefix + "_" + row + "_" + (tmp + 1)).addClass("remove"); jewels[row][tmp + 1] = -1; tmp++; } } jewels[row][col] = -1; }
А затем красиво уберем их со страницы:
/* удаляем гемы с поля */ function gemFade(){ $.each($(".remove"), function(){ movingItems++; $(this).animate({ opacity:0 }, { duration: 200, complete: function() { $(this).remove(); // снова проверяем состояние поля checkMoving(); } }); }); }
После всех анимаций вновь проверим поле вызовом функции checkMoving
.
Сдвигание гемов
После удаления необходимо опустить все камни, оказавшиеся над пустыми клетками. Добавим это действие в функцию checkMoving
:
function checkMoving() { movingItems--; // когда закончилась анимация последнего гема if(movingItems == 0) { // действуем в зависимости от состояния игры switch(gameState) { // после передвижения гемов проверяем поле на появление групп сбора case "switch": case "revert": // проверяем, появились ли группы сбора if(!isStreak(selectedRow, selectedCol) && !isStreak(posY, posX)) { // если групп сбора нет, нужно отменить совершенное движение // а если действие уже отменяется, то вернуться к исходному состоянию ожидания выбора if(gameState != "revert"){ gameState = "revert"; gemSwitch(); } else{ gameState = "pick"; selectedRow = -1; } } else { // если группы сбора есть, нужно их удалить gameState = "remove"; if(isStreak(selectedRow, selectedCol)){ removeGems(selectedRow, selectedCol); } if(isStreak(posY, posX)){ removeGems(posY, posX); } gemFade(); } break; // после удаления нужно "уронить" оставшиеся гемы, чтобы заполнить пустоты case "remove": checkFalling(); break; } } }
А вот и сама функция checkFalling
:
function checkFalling() { let fellDown = 0; for(j = 0; j < numCols; j++) { for(i = numRows - 1; i > 0; i--) { if(jewels[i][j] == -1 && jewels[i - 1][j] >= 0) { $("#" + gemIdPrefix + "_" + (i - 1) + "_" + j) .addClass("fall") .attr("id", gemIdPrefix + "_" + i + "_" + j); jewels[i][j] = jewels[i - 1][j]; jewels[i - 1][j] = -1; fellDown++; } } } $.each($(".fall"), function() { movingItems++; $(this).animate({ top: "+=" + gemSize }, { duration: 100, complete: function() { $(this).removeClass("fall"); checkMoving(); } }); }); // если падать больше нечему, изменяем состояние игры if(fellDown == 0){ gameState = "refill"; movingItems = 1; checkMoving(); } }
Когда все камни опущены вниз, снова проверяем поле – checkMoving
.
Если же опускать больше нечего, поле нужно снова заполнить. Для этого меняем состояние игры на refill
.
Заполнение пустот
Функционал игры практически готов, осталось лишь заполнять поле камнями после удаления и запускать новый цикл игры.
Для этого добавим еще один case
в функции checkMoving
:
function checkMoving() { movingItems--; // когда закончилась анимация последнего гема if(movingItems == 0) { // действуем в зависимости от состояния игры switch(gameState) { // после передвижения гемов проверяем поле на появление групп сбора case "switch": case "revert": // проверяем, появились ли группы сбора if(!isStreak(selectedRow, selectedCol) && !isStreak(posY, posX)) { // если групп сбора нет, нужно отменить совершенное движение // а если действие уже отменяется, то вернуться к исходному состоянию ожидания выбора if(gameState != "revert"){ gameState = "revert"; gemSwitch(); } else{ gameState = "pick"; selectedRow = -1; } } else { // если группы сбора есть, нужно их удалить gameState = "remove"; if(isStreak(selectedRow, selectedCol)){ removeGems(selectedRow, selectedCol); } if(isStreak(posY, posX)){ removeGems(posY, posX); } gemFade(); } break; // после удаления нужно "уронить" оставшиеся гемы, чтобы заполнить пустоты case "remove": checkFalling(); break; // когда все гемы опущены вниз, заполняем пустоты case "refill": placeNewGems(); break; } } }
И реализуем функцию placeNewGems
:
function placeNewGems(){ let gemsPlaced = 0; for(i = 0; i < numCols; i++) { if(jewels[0][i] == -1) { jewels[0][i] = Math.floor(Math.random() * 8); $("#gamefield") .append('<div class = "' + gemClass + '" id = "' + gemIdPrefix + '_0_' + i + '"></div>'); $("#" + gemIdPrefix + "_0_" + i).css({ "top": "4px", "left": (i * gemSize) + 4 + "px", "width": "54px", "height": "54px", "position": "absolute", "border": "1px solid white", "cursor": "pointer", "background-color": bgColors[jewels[0][i]] }); gemsPlaced++; } } /* если появились новые гемы, проверить, нужно ли опустить что-то вниз */ if( gemsPlaced ) { gameState = "remove"; checkFalling(); } else { /* если новых гемов не появилось, проверяем поле на группы сбора */ let combo = 0 for(i = 0; i < numRows; i++) { for(j = 0; j < numCols; j++) { if(j <= numCols - 3 && jewels[i][j] == jewels[i][j + 1] && jewels[i][j] == jewels[i][j + 2]){ combo++; removeGems(i, j); } if(i <= numRows - 3 && jewels[i][j] == jewels[i + 1][j] && jewels[i][j] == jewels[i + 2][j]){ combo++; removeGems(i, j); } } } // удаляем найденные группы сбора if(combo > 0){ gameState = "remove"; gemFade(); } else { // или вновь запускаем цикл игры gameState = "pick"; selectedRow= -1; } } }
Не забываем про все проверки – вуа-ля! – наша игра готова ;)
Добавляем свайп c jQuery плагином
Можно сделать Bejeweled еще красивее и удобнее, добавив возможность двигать камни с помощью свайпов. Вот где нам пригодится плагин touchSwipe. Он замечательно обрабатывает жесты игрока и предоставляет для этого очень удобный интерфейс:
/* отслеживание действий игрока */ $("#gamefield").swipe({ tap: tapHandler, swipe: swipeHandler, swipeStatus: swipeStatusHandler }); function swipeHandler(event, direction) { console.log('swipe', direction); } function swipeStatusHandler(event, phase) { console.log('swipe status', phase); }
Вы можете немного поиграть с полем, чтобы понять, как работают свойства swipe
и swipeStatus
.
Реализуем обработчики. swipeStatusHandler
будет отслеживать начало свайпа и записывать первый выделенный гем. swipeHandler
сделает все остальное:
- отследит направление движения;
- найдет второй гем;
- запустит свитч.
function swipeStatusHandler(event, phase) { // начало свайпа if(phase == "start"){ swipeStart = null; if($(event.target).hasClass("gem")){ swipeStart = event.target; } } } function swipeHandler(event, direction) { // обработка движения if(swipeStart != null) { // фаза ожидания выбора гема if(gameState == "pick"){ // гем, с которого начался свайп selectedRow = parseInt($(swipeStart).attr("id").split("_")[1]); selectedCol = parseInt($(swipeStart).attr("id").split("_")[2]); // второй гем в зависимости от направления свайпа switch(direction) { case "up": if(selectedRow > 0){ $("#marker").hide(); gameState = "switch"; posX = selectedCol; posY = selectedRow - 1; gemSwitch(); } break; case "down": if(selectedRow < numRows - 1){ $("#marker").hide(); gameState = "switch"; posX = selectedCol; posY = selectedRow + 1; gemSwitch(); } break; case "left": if(selectedCol > 0){ $("#marker").hide(); gameState = "switch"; posX = selectedCol - 1; posY = selectedRow; gemSwitch(); } break; case "right": if(selectedCol < numRows - 1){ $("#marker").hide(); gameState = "switch"; posX = selectedCol + 1; posY = selectedRow; gemSwitch(); } break; } } } }
Весь остальной функционал уже готов! Вы можете наслаждаться любимой головоломкой.
[codepen_embed height="500" theme_id="0" slug_hash="XwoYBV" default_tab="result" user="mohnatus-the-lessful"]See the Pen <a href='https://codepen.io/mohnatus-the-lessful/pen/XwoYBV/'>Bejeweled</a> by FurryCat (<a href='https://codepen.io/mohnatus-the-lessful'>@mohnatus-the-lessful</a>) on <a href='https://codepen.io'>CodePen</a>.[/codepen_embed]
Творческое задание
Для упражнения или ради интереса вы можете переписать код игры без использования jQuery или заменить ее на другие JavaScript фреймворки.
Также в прототип можно добавить разные улучшения:
- подсчет очков;
- красивые изображения для гемов;
- бонусы за сбор больших групп (5 и больше камней);
- анимация удаления гемов с поля;
- возможность собирать не только горизонтальные и вертикальные группы (группа может быть любой формы, например, прямоугольной);
- автоматическое перемешивание при отсутствии возможных комбинаций для свитча;
- уровни с разным размером поля или разным количеством цветов.
Оригинал: Complete Bejeweled prototype made with jQuery
Комментарии