В этой статье я пошагово расскажу, как писать самый обычный, классический сапёр при помощи Html5 Canvas, AtomJS, и тайлового движка LibCanvas.
Воспользуемся стандартным шаблоном для «старта» нашего приложения. Важно не забывать подключать js-файлы после создания соответствующих классов.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>LibCanvas :: Mines</title>
<link href="/files/styles.css" rel="stylesheet" />
<script src="/files/js/atom.js"></script>
<script src="/files/js/libcanvas.js"></script>
</head>
<body>
<p><a href="/">Return to index</a></p>
<script>
new function () {
LibCanvas.extract();
atom.dom(function () {
new Mines.Controller();
});
};
</script>
<script src="js/controller.js"></script>
</body>
</html>
Я нарисовал две картинки — мины и флага. Всё остальное мы будем делать «вручную» прям в приложении. Объеденил их в один спрайт для уменьшения количества запросов и предзагружу перед тем, как стартовать приложение. В коде так же можно увидеть нарезку при помощи atom.ImagePreloader:
/** @class Mines.Controller */
atom.declare( 'Mines.Controller', {
initialize: function () {
atom.ImagePreloader.run({
flag: 'flag-mine.png [48:48]{0:0}',
mine: 'flag-mine.png [48:48]{1:0}'
}, this.start.bind(this) );
},
start: function (images) {
this.images = images;
}
});
Отрисовка
Я люблю визуально видеть то, что присходит, потому предпочитаю начинать с программирования отрисовки, а только потом переходить к логике. Для того, чтобы наш код заработал мы воспользуемся LibCanvas.Engines.Tile
. Добавим класс View
, в котором и создадим наш движок. Также нам надо создать простое приложение и привязать движок к приложению при помощи TileEngine.Element.app
. Значение по-умолчанию у нас будет равно закрытой ячейке. Не забудем создать этот View
, в нашем контроллере.
/** @class Mines.View */
atom.declare( 'Mines.View', {
initialize: function (controller, fieldSize) {
this.images = controller.images;
this.engine = new TileEngine({
size: fieldSize,
cellSize: new Size(24, 24),
cellMargin: new Size(0, 0),
defaultValue: 'closed'
})
.setMethod( this.createMethods() );
this.app = new App({
size : this.engine.countSize(),
simple: true
});
this.element = TileEngine.Element.app( this.app, this.engine );
},
/** @class Mines.Controller */
// ...
start: function (images) {
this.images = images;
this.view = new Mines.View( this, new Size(15,8) );
}
Не торопитесь запускать этот код, у нас ещё не определён метод createMethods
класса View
. Давайте вообще определимся с тем, какие у нас могут быть состояния ячейки.
Во время игры мы можем видеть такое:
1. Числа от 1 до 8.
2. Закрытая ячейка
3. Открытая, но пустая ячейка
4. Флажок
После её окончания — следующее:
1. Все мины
2. Если подорвались на одной из них — она выделена
3. Если где-то неверно поставили флаг
Итого, 8 + 3 + 3 = 14 разных состояний. Опишем их все:
/** @class Mines.View */
// ...
createMethods: function () {
return {
1: this.number.bind(this, 1),
2: this.number.bind(this, 2),
3: this.number.bind(this, 3),
4: this.number.bind(this, 4),
5: this.number.bind(this, 5),
6: this.number.bind(this, 6),
7: this.number.bind(this, 7),
8: this.number.bind(this, 8),
explode : this.explode.bind(this),
closed : this.closed .bind(this),
mine : this.mine .bind(this),
flag : this.flag .bind(this),
empty : this.empty .bind(this),
wrong : this.wrong .bind(this)
};
},
Как видите, мы будем вызывать соответствующие методы View
, прибиндив их к текущему контексту. Для того, чтобы видеть, что у нас получается — необходимо добавить соответствующие клетки на поле.
/** @class Mines.Controller */
// ...
start: function (images) {
// ...
// todo: remove after debug
'1 2 3 4 5 6 7 8 empty mine flag explode wrong closed'
.split(' ')
.forEach(function (name, i) {
this.view.engine
.getCellByIndex(new Point(i, 3))
.value = name;
}.bind(this));
Мы просто взяли все индексы и присвоили их по-очереди разным клеткам поля. Теперь отрисовка. В первую очередь нам необходимо создать общий метод, который будет «расскрашивать» ячейку — заливать и обводить необходимым цветом. Если линия шириной в 1 пиксель будет отрисовываться в целые координаты — она будет блуриться (см htmlbook.ru/html5/canvas, ответ на вопрос «В. Почему мы начинаем x и y c 0.5, а не с 0?»), потому воспользуемся экспериментальным методом прямоугольника snapToPixel
/** @class Mines.View */
// ...
color: function (ctx, cell, fillStyle, strokeStyle) {
var strokeRect = cell.rectangle.clone().snapToPixel();
return ctx
.fill( cell.rectangle, fillStyle)
.stroke( strokeRect, strokeStyle );
},
Теперь по-очереди добавляем методы отрисовки. Пустая клетка — просто красим:
/** @class Mines.View */
// ...
empty: function (ctx, cell) {
return this.color(ctx, cell, '#999', '#aaa');
},
Мина и флаг — это просто картинки на пустой клетке:
/** @class Mines.View */
// ...
mine: function (ctx, cell) {
return this
.empty(ctx, cell)
.drawImage( this.images.get('mine'), cell.rectangle );
},
flag: function (ctx, cell) {
return this
.empty(ctx, cell)
.drawImage( this.images.get('flag'), cell.rectangle );
},
Мина, на которой мы подорвались отрисовывается с красным фоном:
/** @class Mines.View */
// ...
explode: function (ctx, cell) {
return this
.color(ctx, cell, '#c00', '#aaa')
.drawImage( this.images.get('mine'), cell.rectangle );
},
Неправильно установленный флаг — красный крест. Отрисовать его достаточно просто. Сначала — ограничиваем отрисовку в пределах нашего прямоугольника при помощи clip
.
Заливаем его фоном, а потом рисуем две красных линии — с верхнего-левого в нижний-правый и с нижнего-левого угла в верхний-правый.
/** @class Mines.View */
// ...
wrong: function (ctx, cell) {
var r = cell.rectangle;
return this.empty(ctx, cell)
.save()
.clip( r )
.set({ lineWidth: Math.round(cell.rectangle.width / 8) })
.stroke( new Line( r.from , r.to ), '#900' )
.stroke( new Line( r.bottomLeft, r.topRight ), '#900' )
.restore();
},
Закрытая ячейка отрисовывается тоже достаточно просто — градиент от тёмного к светлому, с верхнего-левого угла в нижний-правый.
/** @class Mines.View */
// ...
closed: function (ctx, cell) {
return ctx.fill( cell.rectangle,
ctx.createGradient(cell.rectangle, {
0: '#eee', 1: '#aaa'
})
);
},
И, собственно, цифры. Сначала в прототип добавим список цветов для каждой цифры. Нуля нету, потому ставим нул.
Обратите внимание, что первым аргументом функции у нас number
. Именно его мы биндили в методе createMethods
.
После этого рисуем клетку, как пустую, а сверху, текстом, пишем цифру.
/** @class Mines.View */
// ...
numberColors: [null, '#009', '#060', '#550', '#808', '#900', '#555', '#055', '#000' ],
number: function (number, ctx, cell) {
var size = Math.round(cell.rectangle.height * 0.8);
return this.empty(ctx, cell)
.text({
text : number,
color : this.numberColors[number],
size : size,
lineHeight: size,
weight: 'bold',
align : 'center',
to : cell.rectangle
});
}
Наша реализация позволяет нам менять размер ячеек и они будут в любом случае отлично выглядеть:
Генератор мин
Как видим, отрисовка полностью готова. Теперь нам достаточно сделать простое действие и клетка поменяет свой внешний вид.
Удалим наш дебаг-код и создадим инстанс генератора:
/** @class Mines.Controller */
// ..
start: function (images) {
this.images = images;
this.size = new Size(15, 8);
this.mines = 20;
this.view = new Mines.View( this, this.size );
this.generator = new Mines.Generator( this.size, this.mines );
}
Для начала научимся разбрасывать по полю мины. Конечно, было бы неплохо учитывать всякие сомнительные ситуации, но пока у нас для него одно требование — сгенерировать поле после первого клика пользователя, так, чтобы тот не попадался сразу же на мину.
Алгоритм генерации мин у нас будет очень простой — создаём список валидных точек (все, кроме той, на которую кликнули) — метод snapshot
, после этого «выдёргиваем» из них необходимое количество случайных — метод createMines
:
/** @class Mines.Generator */
atom.declare( 'Mines.Generator', {
mines: null,
initialize: function (fieldSize, minesCount) {
this.fieldSize = fieldSize;
this.minesCount = minesCount;
},
/** @private */
snapshot: function (ignore) {
var x, y, point,
result = [],
size = this.fieldSize;
for (y = size.height; y--;) for (x = size.width; x--;) {
point = new Point(x, y);
if (!point.equals(ignore)) {
result.push(point);
}
}
return result;
},
/** @private */
createMines: function (count, ignore) {
var snapshot = this.snapshot( ignore );
return atom.array.create(count, function () {
return snapshot.popRandom();
});
}
});
Следующий шаг — это добавить api-метод, который будет вызываться для генерации этих мин и заносить их в индекс для быстрого доступа. Создадим двумерный хеш со значениями 1, где мина есть и 0, где мины нету. Нам важно использовать именно Integer, причину мы увидим ниже. Теперь у нас есть быстрый метод isMine
для определения, есть ли мина по координате. Метод isReady
будет использоваться, чтобы узнать внешним классам, сгенерировано ли уже минное поле.
/** @class Mines.Generator */
// ..
isReady: function () {
return this.mines != null;
},
isMine: function (point) {
return this.mines[point.y][point.x];
},
generate: function (ignore) {
var mines, minesIndex,
size = this.fieldSize;
mines = this.createMines(this.minesCount, ignore);
minesIndex = atom.array.fillMatrix(size.width, size.height, 0);
mines.forEach(function (point) {
minesIndex[point.y][point.x] = 1;
});
this.mines = minesIndex;
},
Следующий шаг — сделать получение значения клетки, если там мины нет. Алгоритм очень прост — берём всех соседей, которые не выходят за рамки поля, считаем суму их значений. Именно в этом месте то, что мина есть Integer нам и пригодилось.
/** @class Mines.Generator */
// ..
initialize: function (fieldSize, minesCount) {
// эти два метода мы передаём как колбеки, потому привяжем их к контексту
this.bindMethods([ 'isValidPoint', 'isMine' ]);
// ..
getValue: function (point) {
// получаем всех соседей
return this.getNeighbours(point)
// превращаем их в список мин (1 и 0)
.map(this.isMine)
// получаем количество мин в соседних клетках
.sum();
},
// Проверяем, чтобы точка не вышла за пределы поля
isValidPoint: function (point) {
return point.x >= 0
&& point.y >= 0
&& point.x < this.fieldSize.width
&& point.y < this.fieldSize.height;
},
// Список соседей - это все соседи, кроме тех, что выходят за границы
getNeighbours: function (point) {
return point.neighbours.filter( this.isValidPoint );
},
Взаимодействие с пользователем
У нас есть движок игры, теперь необходимо всё это сделать игрой, а не только логикой. Создаём класс Action
, который будет отвечать за все действия пользователя. Первое, что мы сделаем — это реакцию на клик пользователя. При помощи TileEngine.Mouse
мы будем слушать события мыши, связанные с полем. Вешаем Mouse.prevent
на событие 'contextmenu'
, чтобы не выскакивало надоедливое меню. При клике проверяем кнопку. Левая кнопка мыши равна 0, средняя равна 1, правая равна 2. Напомним, что в оригинальной игре клик левой означал открытие клетки, крик средней — открытие всех окружающих, а клик правой — постановка мины.
/** @class Mines.Controller */
// ..
start: function (images) {
// ..
this.action = new Mines.Action(this);
}
/** @class Mines.Action */
atom.declare( 'Mines.Action', {
actions: [ 'open', 'all', 'close' ],
initialize: function (controller) {
this.controller = controller;
this.bindMouse();
},
bindMouse: function () {
var view, mouse;
view = this.controller.view;
mouse = new Mouse(view.app.container.bounds);
new App.MouseHandler({ mouse: mouse, app: view.app })
.subscribe( view.element );
mouse.events.add( 'contextmenu', Mouse.prevent );
new TileEngine.Mouse( view.element, mouse ).events
.add( 'click', function (cell, e) {
this.activate(cell, e.button);
}.bind(this));
},
activate: function (cell, actionCode) {
console.log( cell.point.dump(), actionCode );
}
});
Добавим первую интерактивность. Мы будем получать по индексу название метода, который необходимо вызвать и, заодно напишем самый простой метод — close
. Если клетка закрыта, то устанавливаем на неё флаг, если на клетке уже стоит флаг, то отмечаем её закрытой. Теперь можно увидеть первое взаимодействие — по правой кнопке мыши появляется флаг на клетке.
/** @class Mines.Action */
// ...
activate: function (cell, actionCode) {
if (typeof actionCode == 'number') {
actionCode = this.actions[actionCode];
}
this[actionCode](cell);
},
close: function (cell) {
if (cell.value == 'closed') {
cell.value = 'flag';
} else if (cell.value == 'flag') {
cell.value = 'closed';
}
},
open: function (cell) {
},
all: function (cell) {
}
Теперь опишем открытие клетки. Для начала, открываем только те клетки, которые закрыты. Нечего взаимодействовать с всякими статичными цифрами и флагами. Во-вторых, проверяем, готовы ли наши мины и, если нет — запускаем генератор.
Если открыта мина, то вызываем метод lose
, где помечаем клетку, как взорвавшуюся.
Если в клетке есть цифра, то просто пишем её, никаких других действий с этой клеткой не сделать.
Если клетка пуста, то нам необходимо рекурсивно открывать все клетки вокруг, потому пока создаём метод и помечаем клетку как пустую.
/** @class Mines.Action */
// ...
open: function (cell) {
if (cell.value != 'closed') return;
var value, gen = this.controller.generator;
if (!gen.isReady()) {
gen.generate(cell.point);
}
if (gen.isMine(cell.point)) {
this.lose(cell);
} else {
value = gen.getValue(cell.point);
if (value) {
cell.value = value;
} else {
this.openEmpty(cell);
}
}
},
lose: function () {
cell.value = 'explode';
},
openEmpty: function (cell) {
cell.value = 'empty';
},
Для открытия всех клеток вокруг пустой просто получаем соседей и передаём в метод open
. Этим мы воспользуемся для рекурсивного открытия пустых клеток и для быстрого открытия по средней кнопке мыши.
/** @class Mines.Action */
// ...
openNeighbours: function (cell) {
this.controller.generator
.getNeighbours(cell.point)
.forEach(function (point) {
this.open( this.getCell(point) );
}.bind(this));
},
openEmpty: function (cell) {
cell.value = 'empty';
this.openNeighbours(cell);
},
getCell: function (point) {
return this.controller.view.engine.getCellByIndex(point);
},
all: function (cell) {
if (parseInt(cell.value)) {
this.openNeighbours(cell);
}
},
Проигрышь отображаем так — проходим все клетки, где у нас было закрыто и на самом деле была мина — отрисовываем мину. Где у нас стоял флаг, а на самом деле мины нету — отображаем ошибку. Так же блокируем методы open
и close
после проигрыша.
/** @class Mines.Action */
// ...
lost: false,
lose: function (cell) {
this.lost = true;
cell.value = 'explode';
this.controller.view.engine.cells
.forEach(this.checkCell.bind(this));
},
checkCell: function (cell) {
if (cell.value == 'closed' || cell.value == 'flag') {
var isMine = this.controller.generator.isMine(cell.point);
if (isMine && cell.value == 'closed') {
cell.value = 'mine';
}
if (!isMine && cell.value == 'flag') {
cell.value = 'wrong';
}
}
},
// ...
close: function (cell) {
if (this.lost) return;
// ...
open: function (cell) {
if (this.lost) return;
// ...
Победа!
Осталось отобразить победу, затраченное время и вывести количество мин, которые осталось открыть. Не будем заморачиваться с внешним видом, воспользуемся гиковским, но работающим atom.trace
. Получим количество мин. Посчитаем количество пустых клеток — это количество клеток всего минус количество мин. Каждый раз при открытии клетки будем уменьшать значение пустых на один. Когда они достигнут нуля — игра выиграна. Дадим отрисоваться холсту и с небольшой задержкой отобразим пользователю алерт.
/** @class Mines.Action */
// ...
initialize: function (controller) {
// ...
this.startTime = null;
this.minesLeft = controller.mines;
this.minesTrace = atom.trace(0);
this.changeMines(0);
this.emptyCells = controller.size.width * controller.size.height - this.minesLeft;
},
changeMines: function (delta) {
this.minesLeft += delta;
this.minesTrace.value = "Mines: " + this.minesLeft;
},
// ...
open: function (cell) {
// ...
if (!gen.isReady()) {
// ...
this.startTime = Date.now();
}
if (gen.isMine(cell.point)) {
// ...
} else {
// ...
if (--this.emptyCells == 0) {
this.win();
}
}
},
// ...
win: function () {
var time = Math.round( (Date.now()-this.startTime) / 1000 );
alert.delay(100, window, ['Congratulations! Mines has been neutralized in '+ time +' sec!']);
},
// ...
close: function (cell) {
// ...
if (cell.value == 'closed') {
// ...
this.changeMines(-1);
} else if (cell.value == 'flag') {
// ...
this.changeMines(+1);
}
},
Играть в сапёр
Автор: TheShock