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

от автора

В этой статье я пошагово расскажу, как писать самый обычный, классический сапёр при помощи 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); 		} 	}, 

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

Нравится ли вам формат топика «Краткое описание + Кусок кода»?

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Проголосовал 1 человек. Воздержавшихся нет.

ссылка на оригинал статьи http://habrahabr.ru/post/168435/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *