Изометрический сапёр на LibCanvas (html5)

  • Tutorial

Этот топик будет отличаться от предыдущего топика Классический сапёр на html5 и LibCanvas. Его даже, скорее, можно назвать продолжением. И первая часть содержала пошаговую и детальную инструкцию, как заставить работать игрушку, то в этой части будет пару интересных приёмов, как её «оказуалить».

Играть в изометрический «Сапёр»






Если вы новичок в этом деле, то стоит начинать с первой части. Для тех, кто хочет углубляться я рассмотрю следующие темы на примере изометрического сапёра, построеного на базе LibCanvas:

  • Изометрическая проекция
  • Оптимизация скорости отрисовки через грязный хак
  • Спрайтовые анимации
  • Draggable слои
  • Оптимизация обработчика мыши согласно особенностей приложения


Лирическое отступление


Я всё-таки предлагаю не вдаваться в бессмысленную критику а-ля «классический сапёр — лучше», «ворота такой формы — бред» и «у вас там тенюшка слегка неправильной формулы». Цель была — сделать симпатичную игрушку за короткое время (около 8 часов на полную реализацию артовой части и около 4 часов на полную реализацию кода в неторопливом темпе в свободное время). И раскрыть на базе этой игрушки кое-какие подходы.

Изометрическая проекция


Начнём с самого лёгкого и интересного. Изометрические игры на LibCanvas реализуются путём соединения двух инструментов — фреймворк LibCanvas.App, который описывался в предыдущих двух топиках и класс IsometricEngine.Projection.

Особенность IsometricEngine.Projection в том, что он сам по себе ничего не реализует. Он только предоставляет удобный и быстрый способ перевода 3d координат в пространстве в изометрические 2d координаты и обратно.

var projection = new IsometricEngine.Projection({
    factor: new Point3D(1, 0.5, 1)
});

// Переводим координаты 3d в 2d
var point2d = projection.toIsometric(new Point3D(100, 50, 10));

// Переводим координаты  2d в 3d
var point3d = projection.to3d( mouse.point, 0 );

При переводе с координат 2d всегда неясно, в какой плоскости считать точку, потому дополнительно приходится указывать будущую z-координату

Кстати, что это за factor такой? В теории правильная изометрическая проекция — это проекция с углом в 120°, но на практике в игрушках используется проекция с углом ~117 градусов для того, чтобы линии попали в пиксельную сетку.



Вот как раз factor и позволяет задавать соотношение сторон. Можно выбрать любой из подходов:
// пропорциональная проекция
factor:  new Point3D(    1, 0.5,     1)
// правильная проекция
factor:  new Point3D(0.866, 0.5, 0.866)


Я же в своём приложении наплевал на все правила и просто сделал фактор равным размерам картинки, за счёт чего в координатах [0;0;0] я имею левый угол картинки, а в [1;1;0] — правый
factor: new Point3D(90, 52, 54)




Так как же я приспособил этот инструмент к требованиям приложения? А очень просто. Создаём элемент ячейки навроде такого, где ячейка просто отрисовывается вокруг центра фигуры.

/** @class IsoMines.Cell */
atom.declare( 'IsoMines.Cell', App.Element, {
	renderTo: function (ctx, resources) {
		ctx.drawImage({
			image : resources.get('images').get('static-carcass'),
			center: this.shape.center
		});
	}
});


А потом при помощи проекции создал нужное количество ячеек, передавая им полигоны:

createPolygon: function (point) {
	var p = this.projection;
	
	return new Polygon(
		p.toIsometric(new Point3D(point.x  , point.y  , 0)),
		p.toIsometric(new Point3D(point.x+1, point.y  , 0)),
		p.toIsometric(new Point3D(point.x+1, point.y+1, 0)),
		p.toIsometric(new Point3D(point.x  , point.y+1, 0))
	);
},

createCells: function () {
	var size = this.fieldSize, x, y, point;

	for (x = size.width; x--;) for (y = size.height; y--;) {
		point = new Point(x, y);

		new IsoMines.Cell(this.layer, {
			point : point,
			shape : this.createPoly(point)
		});
}


Очень рекомендую почитать документацию — там довольно интересно, имхо.

Оптимизация скорости отрисовки

А теперь посмотрим на наше приложение и обратим внимание на его особенность. Вся прелесть в том, что у нас объекты не двигаются друг относительно друга и никогда не пересекаются, только соприкасаются. Более того, объекты сами по себе полностью непрозрачные. Потому при изменении можно поступить очень нагло — не стирать ничего, а просто отрисовывать новое изображение ячейки поверх старого.

Для этого, во-первых, необходимо отключить проверку пересечений в LibCanvas.App при создании слоя

this.layer = this.app.createLayer({
	intersection: 'manual'
});


Во-вторых, переопределить метод очистки, сделав его пустым:

/** @class IsoMines.Cell */
atom.declare( 'IsoMines.Cell', App.Element, {
	clearPrevious: function () {}
});


Допустим, мы не можем полностью отказаться от очистки по какой-то причине. Например… Изображение у нас имеет полупрозрачные области или что-то вроде этого. В итоге вы столкнётесь с проблемой как у пользователя kazmiruk:



Это идёт от двух «умолчаний» LibCanvas.App — ограничиващей фигурой (boundingShape) считается boundingRectangle, а стирает оно именно по ней. Достаточно переопределить любое из этих поведений (только одно из них, оба — не имеет смысла):

/** @class IsoMines.Cell */
atom.declare( 'IsoMines.Cell', App.Element, {

	// Делаем сам полигон ограничивающей фигурой, а не его boundingRectangle
	get currentBoundingShape () {
		return this.shape;
	}

	// Стираем именно текущую фигуру, полигон. Будет работать некорректно при движении
	clearPrevious: function (ctx) {
		ctx.clear( this.shape );
	}
});


На самом деле эта оптимизация может использоваться для всех статических или полу-статических слоёв. Не забывайте, что у нас может быть несколько разных Layer и каждый из них иметь свою стратегию поведения:

this.layerStatic  = this.app.createLayer({ intersection: 'manual' });
this.layerDynamic = this.app.createLayer({ intersection: 'auto'   });


Спрайтовые анимации



Следующая интересная тема — как же вставить и заставить работать анимашки — красивые картинки а-ля гифок. Именно для этих целей и существует один из моих любимых плагинов — Animation.

Основная идея в том, что мы передаём png-файл, который содержит множество кадров анимации, которые мы потом собираем в «ролики». Выглядит он приблизительно так, но с полупрозрачностью вместо квадратов на фоне, вдвое большим количеством кадров и размером каждого кадра:
.

Создание анимации делится на три этапа:

1. Нарезка кадров

Используя Animation.Frames нарезаем нашу картинку-сетку на много мелких картинок:

var frames = new Animation.Frames( image, 180, 104 );


У вас в одном спрайте может быть много разных анимаций. Нарезку стоит делать единственный раз, а потом использовать её для всех прототипов.

2. Прототип анимации

Используя Animation.Sheet создаём общее описание анимации — порядок кадров, задержку, зацикленность и даём ссылку на фреймы, которые нарезали выше. Каждый прототип анимации должен встречаться только один раз на приложение. Например, если у вас есть анимация взрыва, которая происходит много раз за приложение — её Animation.Sheet достаточно создать только единожды.

В изометрическом сапёре мне нужны были три анимации — открытие и закрытие замка, открытие и закрытие дверей. У них были однаковые настройки, фреймы, отличалось только название и порядок кадров, потому я сделал всё это коротко и красиво:

this.animationSheets = atom.object.map({
	opening  : atom.array.range( 12, 23),
	closing  : atom.array.range( 23, 12),
	locking  : atom.array.range(  0, 11),
	unlocking: atom.array.range( 11,  0)
}, function (sequence) {
	return new Animation.Sheet({
	    frames: frames,
	    delay : 40,
	    sequence: sequence
	});
});

console.log( this.animationSheets );




3. Сущность анимации


А вот для непосредственного запуска каждый раз при старте создаётся объект Animation, куда мы передаём коллбеки и можем вешаться на события. В сапёре запуск анимаций я организовал при помощи переключения стейта:

Анимированное переключение стейта в IsoMines.Cell
/** @class IsoMines.Cell */
atom.declare( 'IsoMines.Cell', App.Element, {
	preStates: {
		opened: 'opening',
		closed: 'unlocking',
		locked: 'locking'
	},

	changeState: function (state, callback) {
		this.state = this.preStates[state];

		this.animation = new Animation({
		    sheet   : this.sheets[this.state],
		    onUpdate: this.redraw,
		    onStop  : function () {
			    this.state = state;
			    this.animation = null;
			    this.redraw();
			    callback && callback.call(this);
		    }.bind(this)
		});
	},

	getGatesImage: function () {
		return (this.animation && this.animation.get()) || 'gates-' + this.state;
	},

	renderTo: function (ctx) {
		this.drawImage(ctx, this.getGatesImage());
		this.drawImage(ctx, 'static-carcass');
	},

	drawImage: function (ctx, image) {
		if (typeof image == 'string') {
			image = this.layer.app.resources.get('images').get(image);
		}

		ctx.drawImage({ image : image, center: this.shape.center });
	},
});



Draggable слои


Наше приложение получилось довольно крупным и даже при full-screen не влезает в экран компьютера. Нам важно, чтобы пользователь мог достичь любой клетки. Потому прикрутим «draggable» слоя. Т.К. левая и правая кнопка мыши у нас занята — прикрутим скролл по нажатию и тасканию колёсика мышки, а специально для опероводов придётся сделать альтернативный вариант через shift+click. Не очень удобно, но для демки вполне подходит. Я думаю, в полноэкранном режиме идеальным вариантом было бы скроллить, если мышь находится рядом с границами, но это выходило за рамки статьи.

Принцип очень прост, хотя это один из немногих не покрытых документацией классов. Надеюсь, скоро исправлю).

Итак, для начала воспользуемся App.LayerShift, который позволяет сдвигать слой вместе со всеми его элементами и задавать рамки для сдвигания (если мы не хотим, чтобы его утащили куда-то в бесконечность, а потом не могли найти).

После этого мы воспользуемся встроенным классом App.Dragger, который позволяет драгать наш слой. В колбеке старта определим при каких условиях этот драг нужно начинать.

Ну и, зависимо от режима будем определять границы драга — в полноэкранном и свернутом состоянии они будут разные.

Заставляем слой драхаться
	initDragger: function () {
		this.shift = new App.LayerShift(this.layer);

		this.updateShiftLimit();

		new App.Dragger( this.mouse )
			.addLayerShift( this.shift )
			.start(function (e) {
				return e.button == 1 || e.shiftKey;
			});
	},

	updateShiftLimit: function () {
		var padding = new Point(64, 64);

		this.shift.setLimitShift(new Rectangle(
			new Point(this.app.container.size)
				.move(this.layerSize, true)
				.move(padding, true),
			padding
		));
	},



Стоит заметить, что по-умолчанию во время драга замораживается отрисовка. Эту оптимизацию я подсмотрел у старых игрушек, таких как Pharaon и Caesar 3 — когда там смещал карту — анимации прекращались, а «уперевшись в стенку» можно было ярко это заметить. Это поведение изменить достаточно легко, но потребует собственного наследника App.Dragger

Оптимизация обработчика мыши


Тема, которая затрагивается в самый последний момент разработки приложения — оптимизация с профайлером.

Дело в том, что обработчик по-умолчанию изначально работает по довольно медленному алгоритму, но очень универсальному алгоритму — проверяет все фигуры элементов на предмет принадлежности мыши и если на размерах 5*5 это совершенно не чувствуется, то профессиональный размем 30*16 — это полтысячи элементов, которые необходимо пройти каждое движение мыши, а при размере 100*100 будет нереальных 10000 объектов. Квадратичный рост на лицо(

Но у каждого приложения есть свои способы оптимизации — быстрые алгоритмов и кеширование. Для этого в MouseHandler'e предусмотрена возможность передать собственный «поисковик элементов», в конструктор которого мы можем передать все необходимые данные:

this.mouseHandler = new App.MouseHandler({
	mouse: this.mouse, app: this.app,
	search: new IsoMines.FastSearch(this.shift, this.projection)
});


В случае с нашей игрой мы будем заносить все элемент в индексированный хеш, а потом искать благодаря методу IsometricEngine.Projection.to3D — таким образом вместо сложность O(N2) получим сложность O(C) — константную скорость поиска элемента.

Быстрый поиск клетки, на которую кликнули
/** @class IsoMines.FastSearch */
atom.declare( 'IsoMines.FastSearch', App.ElementsMouseSearch, {
	
	initialize: function (shift, projection) {
		this.projection = projection;
		this.shift = shift;
		this.cells = {};
	},

	add: function (cell) {
		return this.set(cell, cell);
	},

	remove: function (cell) {
		return this.set(cell, null);
	},

	set: function (cell, value) {
		this.cells[cell.point.y + '.' + cell.point.x] = value;
		return this;
	},

	findByPoint: function (point) {
		point = point.clone().move(this.shift.getShift(), true);

		var
			path = this.projection.to3D(point),
			cell = this.cells[Math.floor(path.y) + '.' + Math.floor(path.x)];

		return cell ? [ cell ] : [];
	}
});



Для проверки качества оптимизации я сделал карту размером ок 1000 элементов (33х33), сначала отключил быстрый поиск, долго водил мышкой по полю под профайлером, а потом включил быстрый поиск и снова водил мышкой по полю. Результат:

До:


После:


Загруженность приложения упала с 74.4% до 3.4% — более, чем в двадцать раз таким простым способом. Особое преимущетво этого способа по моему мнению в том, что оно позволяет быстро прототипировать приложение на алгоритме по-умолчанию, а оптимизацию перенести на более поздний срок.

Играть в изометрический «Сапёр»



ПостСкриптум


Через пару недель я буду выступать на JavaScript frameworks day в Киеве с темой «AtomJS и LibCanvas». Пока я точно не определился, что именно собираюсь рассказать, потому интересно ваше мнение по поводу двух пунктов в этом опросе, заранее спасибо за ответы.

И да, если у вас есть вопросы, но по какой-то причине вы не можете их задать здесь, на Хабре — пишите на емейл: shocksilien@gmail.com

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

Пользовались ли вы LibCanvas?

  • 2,2%Да, использовал для полноценного проекта13
  • 12,3%Да, игрался73
  • 52,4%Нет, но собираюсь310
  • 33,1%Нет, и не собираюсь196

Какая тема вам бы была интересна на конференции?

  • 24,4%Общее описание фреймворков (как для тех, кто никогда о них не слышал)108
  • 7,2%Более углублённый рассказ о возможностях AtomJS32
  • 20,1%Создание игры на LibCanvas (как в предыдущем топике)89
  • 20,3%Интересные возможности LibCanvas (как в этом топике)90
  • 18,3%Через какие баги и неожиданности пришлось пройти для создания AtomJS и LibCanvas81
  • 9,7%Ничего из вышеперечисленного43
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 52

    +1
    Последнее время игра сапер набирает большую популярность на Хабре!
      +12
      Мои глаза! Все рябит =)
        +1
        Иногда первый клик открывает не пустую ячейку:
          0
          нужно инициализацию ячеек делать после первого клика
            0
            Так и делается.
            0
            не пустую ячейку

            «Не пустая» — это с бомбой. Если там цифра, значит она пустая ;)
              –1
              Согласен, сформулировал неправильно :)
            +14
            Давайте уже StarCraft 1.5
              +20
              Drag the map using (mouse vehicle) or (ctrl+click)
              Mouse vehicle — что это такое? Типа как гуртовщик мыши?
                0
                еще про мышь:
                Что делать несчастным пользунам макоси, трекпада и фаерфокса одновременно?

                контрол + клик это правая кнопка мыши
                правая кнопка мыши в фф вызывает контекстное меню
                на трекпаде нет средней кнопки мыши
                  0
                  А на тачпаде — скролл работает только вверх и вниз (двумя пальцами).
                    0
                    контрол + клик это правая кнопка мыши

                    Заменил на shift+ctrl
                    0
                    Mouse vehicle — что это такое?

                    Ха-ха, вот это ошибки посреди ночи. А ведь сотни раз писал mouse.events.add( 'wheel'
                    +3
                    На время финальной анимации наверное надо блокировать, да, управление? :)
                    А то оно идёт закрывать, а ты открывааааешь :D
                      –5
                      Выглядит неплохо, но почему мины не в земле, а в каких-то ящиках с кучей дверей и полосками?
                      А полоски лучше было бы вокруг всего игрового поля сделать, а не на ящиках. Одну длинную полоску, а не кучу непонятно по какому принципу накленных.
                        +4
                        Выглядит неплохо, но почему мины не в земле, а в каких-то ящиках с кучей дверей и полосками?




                        Одну длинную полоску, а не кучу непонятно по какому принципу накленных.

                        Кстати, принцип вполне понятен — они ставятся на той части дверей, где они открываются, чтобы не ставали, потому что внезапно можно провалиться. Эти полоски обычно рисуются именно в «опасных» местах. И да, поразглядывайте эту картинку:

                        –2
                        У вас ошибка в алгоритме
                        image
                          +12
                          Вы зря #3 отметили.
                            –2
                            А тут как быть? По идее мина должна находится в зелёном секторе, но внезапно игра прекратилась, когда выбрал поле обведённое кругом. При этом неясно откуда у двойки справа внизу появится вторая мина рядом.
                              0
                              [режим телепата] Там ведь скроллить можно[/режим телепата]
                              Мина, которую вы искали, скорее всего, выше зеленого блока.
                                0
                                В зеленом поле стоит еще одна единичка. Частый паттерн в сапере.
                                –3
                                Ну тут то уж точно касяк)

                                UDP: Понял где мой касяк)
                                0
                                У вас ошибка в алгоритме

                                Там нету ошибок в алгоритме ;) Я сыграл не менее 10 полных боёв и всегда программа срабатывала так, как должна
                                0
                                оу, точно.
                                приношу свои извинения.
                                но все же не хватает обозначения ошибки
                                  0
                                  В оригинальном сапере можно было отмечать возможные места с минами и кликнуть после этого по ним нельзя было. А каким образом это можно делать тут?
                                    0
                                    Правой кнопкой ведь — замок ставится.
                                      0
                                      Да, все верно. Извините, сморозил.
                                    +1
                                    Прочитал сначала «Изотермический сапер». Долго думал.
                                      0
                                      Эзотерический.
                                      0
                                      www.dropbox.com/s/1hw6t96n3pqp0em/Screenshot%20from%202013-02-08%2014%3A49%3A17.png

                                      Кто-нибудь знает выиграть в этой ситуации?
                                        0
                                        Посчитать число оставшихся мин. Если осталась одна или три — всё понятно. Если две, действовать можно только наугад.
                                          0
                                          там явно оставалось 2 мины
                                          но вдруг есть метод, как такое просчитывать
                                            0
                                            Почему явно? Может быть и 1 и 3)
                                            Нет, к сожалению, метода нету…
                                              0
                                              и правда и 1 и 3 может быть!

                                              (на больших полях оказывается может быть намного интереснее)
                                          +1
                                          Ох, сколько раз я подобную ситуацию встречал, играя в стандартного виндового сапёра. Ну и в жизни тоже, хе-хе.
                                            +3
                                            В Simon Tatham's Portable Puzzle Collection отличный сапер — там алгоритм гарантирует, что можно открыть все мины без необходимости угадывать.
                                              0
                                              Описал бы кто-то этот алгоритм)
                                                0
                                                а в чем пробема-то?
                                                клетка, в которую ткнули первый раз — всегда 0
                                                ситуаций где 50/50 быть не должно. то есть вот такого быть не должно:
                                                x22x
                                                2**2
                                                2**2
                                                x22x
                                                  0
                                                  Это вы называете описанием алгоритма? Спасибо, я тоже могу привести кучу примеров, например из предыдущего топика:
                                                  image

                                                  Но это ни на грамм не приблизит к написанию алгоритма, чтобы таких ситуаций не было.
                                                    0
                                                    Алгоритм прост — рандомная генерация с фильтрацией ситуаций 50/50.
                                                    То есть генерим, решаем, если есть ситуации 50/50 — либо регеним всё, либо эпсилон окружность вокруг спорного момента.
                                                      0
                                                      У того сапера открыты исходники, но солвер там не тривиальный. Вот если бы еще кто-нибудь переписал все паззлы на LibCanvas… :)
                                                        0
                                                        Ага, у меня они даже не запускаются(
                                                        Ну если будут желающие — я обязательно окажу консультационную помощь)
                                            0
                                            На правую кнопку срабатывают жесты мыши в Опере. Надо, наверное, preventDefault() делать. И да, не хватает информации о числе оставшихся мин.
                                              0
                                              Надо, наверное, preventDefault() делать

                                              Делаю. Но это ведь Опера.
                                              this.mouse.events
                                              	.add('click', Mouse.prevent)
                                              	.add('contextmenu', Mouse.prevent);
                                              
                                              +5
                                              TheShock, с каждым топиком удивляете все больше и больше :-)
                                                +3
                                                Рад, если получается удивлять)
                                                0
                                                Баг: сразу после победы в течение какого-то времени можно снова начать открывать ячейки.
                                                  +1
                                                  TheShock, кстати было бы интересно поглядеть Ваше выступление на JavaScript frameworks day. Да и на другие тоже :). Не в курсе, планируется ли публикация докладов с этого мероприятия?
                                                    +1
                                                    Кстати, прошу прощения, что забыл скинуть это видео:
                                                    www.youtube.com/watch?v=d-qO7v0lT-U
                                                      0
                                                      Вот зря вы 3 дня не подождали :). На это (ну или похожее) видео наткнулся сам, но все равно спасибо.
                                                    0
                                                    Круто, но совсем неиграбельно. Очень неудобно ориентироваться на поле в такой проекции.
                                                      0
                                                      Ну дело было не в играбельности.
                                                      Хотя, скажу честно, на практике очень даже играбельно, через игр 15 привыкаешь и действия становятся интуитивными)

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

                                                    Самое читаемое