В этой статье я пошагово расскажу, как писать самый обычный, классический сапёр при помощи 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); } },
Играть в сапёр
ссылка на оригинал статьи http://habrahabr.ru/post/168435/
Добавить комментарий