Pull to refresh

Классический сапёр на html5 и LibCanvas

Reading time10 min
Views34K


В этой статье я пошагово расскажу, как писать самый обычный, классический сапёр при помощи Html5 Canvas, AtomJS, и тайлового движка LibCanvas.

А также смотрите продолжение — "Изометрический сапёр на LibCanvas (html5)"


Воспользуемся стандартным шаблоном для «старта» нашего приложения. Важно не забывать подключать 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);
		}
	},


Играть в сапёр

Only registered users can participate in poll. Log in, please.
Нравится ли вам формат топика «Краткое описание + Кусок кода»?
88.86% да670
11.14% нет84
754 users voted. 158 users abstained.
Tags:
Hubs:
+73
Comments58

Articles

Change theme settings