Изометрия - о реализации

от
GameDev    изометрия, javascript

Этим летом решил уделить немного времени на изометрическую проекцию и попробовать сделать свой велосипед.

Первое что пришло в голову - генерировать тайлы из выбранной текстурки, накладывая ее на стороны тайла программно, на подобии штуки Magatino для изо-майнкрафта. Деформация делается достаточно просто.

  1. function createTile(img) {
  2.     //создаем канвас размером с тайлик
  3.     var isometric = document.createElement("canvas");
  4.     isometric.width = img.width * 2;
  5.     isometric.height = img.height * 2;
  6.     var g = isometric.getContext("2d"); //косплей j2me
  7.  
  8.     g.setTransform(1, -0.5, 1, 0.5, 0, img.height/2);
  9.     g.drawImage(img, -1, 0, img.width+1, img.height+1); //верхняя часть блока
  10.  
  11.     //раскомментировать в 2020-ом
  12.     //g.filter = "brightness(75%)";
  13.     var temp = brightnessImage(img, 75); //задаем яркость
  14.     g.setTransform(1, 0.5, 0, 1, 0, 0);
  15.     g.drawImage(temp, 0, temp.height/2); //левая часть блока
  16.  
  17.     //g.filter = "brightness(50%)";
  18.     temp = brightnessImage(img, 50);
  19.     g.setTransform(1, -0.5, 0, 1, 0, 0);
  20.     g.drawImage(temp, temp.width, temp.height*1.5); //правая часть блока
  21.  
  22.     return isometric;
  23. }
  24.  
  25. /* Так как свойство filter довольно плохо поддерживается канвасом, я использовал альтернативную реализацию */
  26.  
  27. function brightnessImage(img, adjustment) {
  28.     var canvas = document.createElement("canvas");
  29.     canvas.width = img.width;
  30.     canvas.height = img.height;
  31.     var g = canvas.getContext("2d");
  32.     g.drawImage(img, 0, 0);
  33.     var imageData = g.getImageData(0, 0, canvas.width, canvas.height);
  34.     var d = imageData.data;
  35.     for (var i=0; i<d.length; i+=4) { //правка rgb каналов, скип альфы
  36.         d[i] = d[i]/100*adjustment;
  37.         d[i+1] = d[i+1]/100*adjustment;
  38.         d[i+2] = d[i+2]/100*adjustment;
  39.     }
  40.     g.putImageData(imageData, 0, 0);
  41.  
  42.     return canvas;
  43. }

Теперь можно отрисовывать карту. После нескольких неудачных попыток, все получается даже без включения мозга ().

  1. Map.prototype.init = function() {
  2.     //настоящий размер тайлов 64x64
  3.     this.tsizex = 64;
  4.     this.tsizey = this.tsizex / 2;
  5.     this.tsizez = this.tsizex;
  6. }
  7.  
  8. Map.prototype.draw = function() {
  9.     for (var iz = 0; iz < this.d; iz++)
  10.         for (var iy = 0; iy < this.h; iy++)
  11.             for (var ix = 0; ix < this.w; ix++) {
  12.                 var id = this.layers[iz][iy][ix]; //трехмерный массив карты
  13.                 var x = Math.floor(ix * this.tsizex - iy * this.tsizex);
  14.                 var y = Math.floor(iy * this.tsizey + ix * this.tsizey - iz*this.tsizez);
  15.                 if (id > 0) g.drawImage(this.tiles[id], x, y); //this.tiles - массив картинок
  16.             }
  17. }

Но в таком варианте карта будет отрисовываться наполовину за экраном, а не с координат 0,0. Чтобы это исправить, нужно при отрисовке учитывать этот самый выезд за экран. Вынесем его в две переменные:
  1. this.offsetx = this.tsizex*(this.h-1);
  2. this.offsety = this.tsizez*(this.d-1);
И поправим координаты для Map.prototype.draw:
  1. var x = Math.floor(ix * this.tsizex - iy * this.tsizex + this.offsetx);
  2. var y = Math.floor(iy * this.tsizey + ix * this.tsizey - iz*this.tsizez + this.offsety);

Чтобы в дальнейшем не мучатся с данной формулой отрисовки, предлагаю вынести это в отдельные методы getTileX и getTileY, куда аргументами передавать обычные значения x,y,z, получая на выходе лишь x или y. А заодно, добавим туда учитывание игровой камеры, которую обозначим переменными cx и cy. И как бонус, сделаем еще пару методов для конвертации обычных координат в изометрические (это будет полезно например, чтобы сделать выбор тайлов карты курсором). Всю эту гадость я, пожалуй, запихну в спойлер.
Открыть спойлер

К сожалению, все оказалось не так просто. Хромог совсем не захотел в отрисовку изображений с отключенным сглаживанием, что весьма заметно, если нижние стороны тайлов очень контрастные или вовсе отсутствуют.
Возможные решения проблемы:
1) Растягивать текстуры на несколько пикселей в стороны.
В таком случае одиночные тайлы, например слоями выше, будут залезать за свою территорию, в большинстве вариаций это будет не критично.
2) Использовать готовые пиксельные маски, накладывая на них текстуры
Есть несколько вариаций стыковок тайлов (types.png), которыми пользуются большинство художников, полностью избавляясь от сглаживания по краям.

В конечном варианте я решил использовать маски без текстур, лишь выбирая необходимую форму (блок/полублок/склон) и изменяя ее цвет для создания нужного тайла.
scrn.png
Пришло время делать игрока и проверку тайлов на глубину. Алгоритм очень прост - проверять координаты всех тайлов с игроком перед отрисовкой, чтобы точно знать, какие тайлы рисовать за игроком, а какие - перед. Для сортировки я использовал нативный метод Array.prototype.sort() с данной функцией.
  1. function sortObjects(a, b) {
  2.     var alen = a.x + a.y + a.z;
  3.     var blen = b.x + b.y + b.z;
  4.     if (alen > blen) return 1;
  5.     else if (alen < blen) return -1;
  6.     else if (a.type) return 1; //это свойство имеет лишь объект игрока, добавляя ему немного приоритета
  7. }
Но все-таки, проверять, например, карту 10х10х10 с игроком - это сортировать 1000 тайлов каждый кадр, что явно отрицательно скажется на производительности. Мы может откинуть некоторые z-слои карты, на которых игрока точно не будет, или снизить кол-во тайлов до минимума, стерев те, что не видны игроку, но в данном случае есть способ проще.
Достаточно проверять лишь те тайлы, за которые возможно зайти, а всю карту рисовать одной фоновой пикчей.

Окей, с помощью проверки на соседние тайлы при единственной отрисовке карты в фон, мы определились с тайлами, за которые игрок может зайти и начали рисовать их поверх остальной карты. Как же сделать корректную отрисовку в данном случае? Довольно просто memes.jpg

Изначально я обрезал тайлы, имеющие глубину таким образом (красными линиями обведена часть тайла, которая останется после обрезания, оставшаяся часть тайла становилась прозрачной), но гораздо надежнее - просто маскировать каждый тайлик с глубиной под фон.

Для этого воспользуемся свойством канваса, под названием globalCompositeOperation.
По умолчанию, если мы рисуем сначала синий квадрат, а потом красный круг, то они отобразятся таким образом: source-over.png
Данное свойство отвечает за перекрытие цветов при отрисовке. Установим ему значение 'source-atop' и получим необходимый результат source-atop.png, осталось лишь заменить квадрат на наш тайл с глубиной, а круг - на игровую карту. Можно сказать, мы вырезаем из карты нужный нам фрагмент нужной формы и рисуем только его в наш тайлик.

Как это выглядит в игре
Вариант с подсвеченными тайлами
Тайлы с глубиной без фона

Что ж, техническая часть подходит к концу. Я хотел бы рассказать про реализацию коллизии, но лично я смог сделать столкновение лишь с полными блоками, скопировав говнокод из портал мобиле. Делается это так же как и в 2D платформерах, с двумя лишними проверками на пол и потолок.

Из-за практически полного отсутствия геймплея и графики, я с гордостью называю эту демку в честь No Man's Sky, удачного брождения по картам.
http://mssite.org/projects/dev/NoPonySky/
WASD / стрелки / пробел - передвижение
Кнопка 9 - отображение только тайлов с глубиной
FAQ по редактору карт

Как итог, стоит заметить, что реализовывать сложную изометрию в 2D сейчас довольно глупо. В первую очередь это будет касаться освещения и теней в игре (ладно, я просто не знаю, как можно сделать тень у игрока в текущем демо), да и более динамические и красивые вещи намного проще будет сделать в 3D. Зато теперь у меня есть редактор!
Ставь класс, если тратил по полмесяца на бесполезную хрень ;-)
  • +15
  • views 5372