Этот топик будет отличаться от предыдущего топика Классический сапёр на 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, куда мы передаём коллбеки и можем вешаться на события. В сапёре запуск анимаций я организовал при помощи переключения стейта:
/** @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» слоя. Т.К. левая и правая кнопка мыши у нас занята — прикрутим скролл по нажатию и тасканию колёсика мышки, а специально для опероводов придётся сделать альтернативный вариант через ctrl+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.ctrlKey;
});
},
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(N
2)
получим сложность 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». Пока я точно не определился, что именно собираюсь рассказать, потому интересно ваше мнение по поводу двух пунктов в этом опросе, заранее спасибо за ответы.
Автор: TheShock