
В этой статье я опишу процесс разработки игры Sokoban на JavaScript-библиотеке Bacon.js.
Увидеть результат можно здесь. Выглядеть это будет примерно, как на картинке. Поскольку игрушка будет не на канвасе, а обычный DHTML, то параллельно я буду пользоваться и jQuery, чтобы облегчить себе работу с DOM. Заодно подготовлю почву для следующей статьи, которая будет о тестировании приложений на qUnit и Jasmine.
Подготовка.
Игра у меня будет минималистична. Таблица одинаковых DHTML ячеек. n строк, m столбцов. Игрок - зеленый, стены - серые, блоки - желтые, целевые ячейки (куда надо придвинуть блоки) - синие. Все очень просто, а разметка будет такой:<div id="game">
<div class="row"> <!-- 1-aя строка -->
<div class="cell"></div> <!-- 1-я ячейка -->
<div class="cell"></div> <!-- 2-я ячейка -->
<!-- и так далее -->
</div>
</div>
А конечный результат будет выглядеть как на картинке в аннотации. Для этого нам понадобиться следующий CSS:
#game{
color: white;
text-align: center;
font-family: Arial, sans-serif;
background-color: gray;
display: inline-block;
margin: 0;
border: solid 5px rgba(0,0,0,0.4);
border-radius: 5px;
padding: 0 2px;
padding-bottom: 2px;
box-shadow: 3px 4px 3px rgba(0,0,0,0.31);
}
.row{
margin: 2px 0px;
padding: 0px;
height: 14px;
}
.cell{
width:14px;
height: 14px;
border-radius: 2px;
background-color: white;
display: inline-block;
margin: 1px;
padding: 0px
}
Вот собственно и все что нам нужно будет, ну и конечно jQuery и Bacon.js.Генерация игрового поля. Обобщения.
Инициализировать игру хотелось бы наиболее простым из возможных способов. Например:var game = Sokoban(level);
Где level - это объект, который описывает карту игры которую мы играем. Например с помощью ширины, высоты и координат разных объектов. Притом, чтобы не было частных примеров, но была возможность создания неограниченного количества игровых полей, которые можно было бы легко отображать, понимать и редактировать. Изначально я вспомнил много удачных способов кодировки координат, но воспользуюсь самым тривиальным координатным методом, хотя он далеко не самый производительный (привет Декарту). Например:var level1 = {
width : 8, //ширина игрового поля
height : 8, //высота игрового поля
player : {x:1,y:1}, //изначальное место игрока
blocks : [{x:3,y:3},{x:3,y:4}], //место блоков
goals : [{x:5,y:4},{x:5,y:5}], //место целей
walls : [{x:7,y:1},{x:7,y:2},{x:7,y:3},{x:6,y:3},{x:6,y:4},{x:2,y:6},{x:3,y:6},{x:5,y:2},{x:6,y:2}] //стены
}
Первоначально, готовим уровень. Нужен конструктор, который проинициализирует нам уровень и отрисует все, что нужно. Простите за большие скачки, но я не хочу заострять внимание на деталях jQuery, предполагая, что читатель уже знаком со всеми используемыми мною технологиями.var Sokoban = function(level){
var lvl = level; //т.н. приватная переменная
var $container = $("#game"); //контейнер, где будет происходить игру
//здесь происходит инициализация. Я решаю вынести все в отдельную функцию, чтобы не захламлять scope конструктора
var init = function(){
for(var i = 0; i<lvl.height;
$container.append("<div class='row'></div>"); //добавим новую строку
var $row = $($(".row", $container)[i]); //получим новую строку
for(var j = 0; j<lvl.width; j++){
$row.append("<div class='cell'></div>"); //добавим сюда ячейку
if((j==0 || j==lvl.width-1) || (i==0 || i==lvl.height-1))
//если ячейка на границе карты, то добавим ее координаты в массив стен
lvl.walls.push({x:j,y:i})
}
}
}
init();
return this;
}
И так, этот код генерит для нас DHTML уровня, осталось только разукрасить все объекты игрового поля, для этого добавим пару функций в тело конструктора:
var draw = function(pos, color){
var $row = $($(".row")[pos.y]), $cell = $($(".cell", $row)[pos.x]);
$cell.css("background-color",color);
//это функция красит ячейку с координатами {x:pos.x, y:pos.y} в цвет color
}
//эта функция отрисовывает весь уровень, она нам еще неоднократно понадобится
var drawLvl = function(){
//порядок важен так как - это суть слои
for(var i=0,l=lvl.walls.length;i<l;i++)
draw(lvl.walls[i], "rgb(184, 184, 184)");
for(var i=0,l=lvl.goals.length;i<l;i++)
draw(lvl.goals[i], "rgb(255, 235, 0)");
for(var i=0,l=lvl.blocks.length;i<l;i++)
draw(lvl.blocks[i], "rgb(181, 184, 255)");
draw(lvl.player, "rgb(130, 212, 130)");
}
В оконечности мы получаем конструктор, который принимает объект, описывающей карту игры и на выходе создает DHTML. Для той переменной level, что я привел в начале раздела на выходе получится именно то поле, которое я картинкой приложил к статье.var Sokoban = function(level){
...приватные переменные
var draw = function(pos, color) { ... }
var drawLvl = function() { ... }
var init = function(){ ... }
init();
drawLvl();
return this;
}
Теперь можем перейти к самому интересному.
Управление и потоки событий.
Эта игра управляется всего четырьмя клавишами. Вверх, вниз, влево, вправо. Это и есть наш поток управляющих событий. Давайте порассуждаем что нам нужно для создания стройного потока управляющих событий. И как разделить одни события от других.
Недолго подумав получаем, что надо слушать события типа keydown и фильтровать из них только те, чье свойство keyCode находится в диапазоне от 37 до 40 (клавиши стрелок). Далее вычисляем направление движения, чтобы передать переменной direction нужное значение, т.к. именно опираясь на нее мы будем перекрашивать наше игровое поле.
Шаг 1. Получаем поток событий всех нажатий на клавиши.
var keyDowns = $(document).asEventStream("keydown");
Шаг 2. Фильтруем из них те события, у которых свойство keyCode в диапазоне от 37 до 40.
var arrowDowns = keyDowns.filter(isArrows);
function isArrows(e){return e.keyCode >= 37 && e.keyCode <= 40}
Применяя метод filter bacon.js мы вызываем функцию, переданную аргументом в этот метод, куда в свою очередь аргументом передается событие сгенерированное браузером и отловленное нашим "слушателем событий". Теперь arrowDowns - это поток таких событий, что прошли через фильтр isArrows. Сам метод filter пропускает события, обработка которых в функции переданной в метод filter аргументом вернула в значении true.
Шаг 3. Вычисляем направление движения
var currentDirection = arrowDowns.map(findDirection);
function findDirection(e){return { x : e.keyCode % 2 ? e.keyCode - 38 : 0, y : !(e.keyCode % 2) ? e.keyCode - 39 : 0 }}
//функция возвращает объект типа {x:1,y:0}, что означает "вправо"
//приращение координаты x, при неизменной координате y
Шаг 4. Обновить значение переменной direction
currentDirection.onValue(updateDirection);
function updateDirection(x){
direction.x = x.x;
direction.y = x.y;
}
Перед тем как двигаться дальше, хочу обратить ваше внимание на пару моментов. Уже на этом этапе проявился декларативный характер Bacon.js. Произошло разделение чистых функций (isArrows, findDirection) от производителей сайд-эффектов (updateDirection).
Теперь, если мы в функции updateDirection добавим вывод в консоль переменной direction, то на выходе увидим:
Теперь мы можем двигаться дальше к управлению и созданию побочных действий. Главная декларативная логика развернется именно здесь.
Теперь, если мы в функции updateDirection добавим вывод в консоль переменной direction, то на выходе увидим:
Key pressed : Console output
----------------------------------
left : {x:-1;y:0}
up : {x:0;y:-1}
right : {x:1;y:0}
down : {x:0;y:1}
----------------------------------
Прошу не забывать, что начало координат находится в левом верхнем углу.
Теперь мы можем двигаться дальше к управлению и созданию побочных действий. Главная декларативная логика развернется именно здесь.
Игровая логика.
Логично было бы подумать, что первое, что я должен реализовать - это движение игрока (тобишь зеленой ячейки) по полю. По правилам двигаться можно всюду, кроме стен и блоков припертых к стенам. Здесь чуть более сложная логика, ежели просто вычислить направление движения в зависимости от нажатия стрелок, поэтому нужно разделить игровую логику на три этапа:
Этап 1: Реализация движения игрока по пустым клеткам (если на клетке нет ничего, кроме конечной цели т.н. goal). В таком случае движение безусловно.
Этап 2: Если перед игроком блок, то
Этап 3: Проверить все ли блоки на целевых ячейках.
Начнем с первого этапа и реализуем движение игрока. В свою очередь разделим этап на несколько шагов. Короче говоря, нам нужно получить поток данных для игрока, вычислить куда направляется игрок, проверить допустимо ли это и двинуть его туда, если допустимо.
Шаг 1: Получим объект игрока для потока событий arrowDowns
Этап 1: Реализация движения игрока по пустым клеткам (если на клетке нет ничего, кроме конечной цели т.н. goal). В таком случае движение безусловно.
Этап 2: Если перед игроком блок, то
- если блок приперт к стене, так что его невозможно толкнуть по направлению движения, то ничего не делать.
- если блок свободен, то двигать игрока и блок.
Этап 3: Проверить все ли блоки на целевых ячейках.
Начнем с первого этапа и реализуем движение игрока. В свою очередь разделим этап на несколько шагов. Короче говоря, нам нужно получить поток данных для игрока, вычислить куда направляется игрок, проверить допустимо ли это и двинуть его туда, если допустимо.
Шаг 1: Получим объект игрока для потока событий arrowDowns
var player = arrowDowns.map(function(){return lvl.player})
//теперь player - это lvl.player, инициированный каждым событием из потока arrowDowns
Шаг 2: Вычислим следующее местоположение игрока в соответствии с направлением его движения:
var playerMove = player.map(move);
//функция move возвращает координаты ячейки куда должен переместиться игрок
function move(cell){return {x:cell.x+direction.x, y:cell.y+direction.y, i:cell.i}}
Поскольку у нас значению направления движения в соответствие поставлена функция вычисляющая ее всякий раз когда происходит нужное событие (нажатие на клавиши стрелок), то нам не нужно вычислять всякий раз значение вектора движения. Мы можем просто пользоваться этой переменной, так если бы она сама обновлялась всякий раз, когда направление движение менялось.
Шаг 3: Получим поток таких событий, что следующая ячейка пуста:
var moveToEmpty = playerMove.filter(isEmpty);
//эта вычисляет является ли следующая ячейка пустой
function isEmpty(e){
for(var i=0,l=lvl.walls.length;i<l;i++){
if(e.x == lvl.walls[i].x && e.y == lvl.walls[i].y)
return false
}
for(var i=0,l=lvl.blocks.length;i<l;i++){
if(e.x== lvl.blocks[i].x && e.y == lvl.blocks[i].y)
return false;
}
return true;
}
Таким образом мы проверяем может ли игрок двинуться на следующую ячейку.
Шаг 4: Двинем игрока и перекрасим поле:
moveToEmpty.onValue(movePlayer);
//двигает игрока по координатам объекта nov
//и перерисовывает уровень целиком
function movePlayer(nov){
draw(that.player, "white");
lvl.player.x = nov.x;
lvl.player.y = nov.y;
drawLvl();
}
Поскольку поток событий moveToEmpty подразумевает все те события, которые как бы говорят нам, что "вот пользователь нажал на клавишу, которая должна двинуть игрока на пустую клетку", то мы смело красим поле в нужную конфигурацию.Дальше мы можем приступить к реализации второго этапа, где игрок движется в сторону блока. Этот этап также разделю на шаги.
Шаг 1: Создаем поток таких событий, что следующая ячейка, куда движется игрок - является блоком.
var moveOnBlock = playerMove.filter(isBlock);
function isBlock(e){
for(var i=0,l=lvl.blocks.length;i<l;i++){
if( e.x== lvl.blocks[i].x && e.y == lvl.blocks[i].y )
return true;
}
return false;
}
Шаг 2: Вычислим координаты блока и его индекс в массива lvl.blocks, чтобы перерисоватьvar blockMove = moveOnBlock.map(blockMove);
function blockMove(e){
for(var i=0,l=lvl.blocks.length;i<l;i++){
if( e.x== lvl.blocks[i].x && e.y == lvl.blocks[i].y )
return {x:e.x,y:e.y,i:i} // здесь i - это индекс блока в массиве lvl.blocks
}
}
Шаг 3: Двинем блок, игрока и перерисуем карту.var blockMoveToEmpty = blockMove.map(move).filter(isEmpty);
blockMoveToEmpty.onValue(moveBlock);
function moveBlock(cell){
draw(lvl.player, "white");
lvl.player.x = cell.x-direction.x;
lvl.player.y = cell.y-direction.y; //обновляется местоположение игрока
lvl.blocks[cell.i].x = cell.x;
lvl.blocks[cell.i].y = cell.y; //обновляется местоположение блока
drawLvl()
}
Вот собственно и вся игровая логика. В финальном этапе можно написать функцию, которая вычисляет находятся ли все блоки на нужных местах.Эту функцию можно вызвать по завершению перерисовки в moveBlock, можно файрить событие redrawed и собрать поток этих событий, чтобы слушать именно и его и реагировать на него. Вариантов много. Приводить их не буду т.к. в них ничего нового относительно поведения bacon.js не будет.
Послесловие.
Строго говоря я написал плохую игру. Да, она универсальна и работает правильно, да генерировать новые уровни легко и написать редактор уровней будет достаточно просто. Но игра мало производительна. Каждое движение игрока и/или блока заставляет перерисовывать всю игровую карту. Если карта будет достаточно большой, то лаги неизбежны. Координатная система конечно, интуитивно ясна, но все равно слишком объемна и так далее. Но ведь я писал эту статью, чтобы показать функциональные возможности JS на Bacon. Но ведь и тут я не использовал все возможности bacon.js, череда методов map и filter легко заменима на scan, например. Я оставил за собой право упростить код игры до максимума.
Главное, я показал декларативную ясность, которую приобретает код, стоит только начать пользоваться bacon.js. По-началу все казалось излишними нагромождениями, но с расширением функционала, добавлять новые элементы стало проще так как вся логика уже была разработана, осталось только описать их в простом декларативном стиле. И это, пожалуй, главное преимущество FRP.
В гитхаб репозитории (ссылка ниже) лежит версия, куда добавлен новый функционал, если посмотреть, можно увидеть, как легко это было сделать.
--------------------------------------
Дополнительные ссылки:
Комментариев нет:
Отправить комментарий