Основанное на координатной сетке изометрическое представление.

Основанное на координатной сетке изометрическое представление.

Автор: Мартин Эктор (pfhoenix@gmail.com)

Введение.

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

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


Изображение выше показывает двумерную сетку 4 x 4, видно, что отсчет индексов начинается с нуля. Мы будем работать с массивами и сетками, намного большими чем эта, но она показывает очень важное свойство, что все массивы (и почти все структуры памяти) — нумеруются с нуля. Причина для этого проста — всякий раз, когда мы обращаемся к массиву, мы запрашиваем значение или объект с определенным смещении от начала массива. Нулевое смещение означает первую «клетку» связанной размерности. Мы хотим, чтобы наш мир был привязан к сетке координат, и массив дает нам отличное виртуальное представление .

Прежде, чем мы сможем определить подходящий массив, мы должны выбрать, как мир будет представлен в коде. Мы можем использовать простую систему нумерации, чтобы указать , что находится в той или иной клетке (используем 1, для стены, 2, для дерева, 3, для пола, и т.д). Такая система ограничивает, но если использовать объектно-ориентированную парадигму(в нашем случае в языке C #), у нас будут все инструменты, чтобы извлечь пользу от использования намного более гибкой системы, объединив понятие клетки в класс. Это даст возможность нам определеить и описать все свойства клетки в единственном объекте. Так, в каких свойствах мы нуждаемся? Мы нуждаемся пока только в трех свойствах:

class MapGrid

{


public Point Coordinate;


public
uint ProcessID;


public Texture2D Texture;


public MapGrid(int x, int y, Texture2D t)

{

Coordinate.X = x;

Coordinate.Y = y;

Texture = t;

}

}

Тип Point — структура, которая состоит из двух целых полей, X и Y. Это делает его хорошим типом, чтобы использовать для координатной пары (X, Y) . Если Texture может показаться очевидным (начало текстуры, которую мы рисуем на данной клетке), то ProcessID, не так ясен. Давайте не будем разбираться с этим сейчас, поскольку это будет объяснено скоро.

Теперь, когда у нас есть идея того, как наша координатная сетка будет представлена, давайте подключим другой класс, который будет управлять координатной сеткой и миром в целом:

class Map

{


private
uint ProcessID = 0;


protected MapGrid[,] Grids;


private
int _mapwidth;


public
int Width

{

get { return _mapwidth; }

}


private
int _mapheight;


public
int Height

{

get { return _mapheight; }

}


public Map()

{

}


public
void Initialize(int w, int h, Texture2D defaulttex)

{

_mapwidth = w;

_mapheight = h;

Grids = new MapGrid[w, h];


for (int j = 0; j < h; j++)


for (int i = 0; i < w; i++)

Grids[i, j] = new MapGrid(i, j, defaulttex);


return;

}


public MapGrid GetGrid(int x, int y)

{


if (x < 0) return
null;


if (y < 0) return
null;


if (x >= Width) return
null;


if (y >= Height) return
null;


return Grids[x, y];

}


public MapGrid GetGrid(Point p)

{


return GetGrid(p.X, p.Y);

}

}

Естественно, это далеко не весь код в нашем классе Map, это только начало. В этом классе так же можно заметить ProcessID — они связаны с предыдущим, но мы подождем и объясним это позже. Мы объявляем свой массив клеток, названных Grid, как двумерный массив MapGrids. Мы также определяем поля width и height, которые мы скрываем средствами доступа и оборачиваем в методы не позволяющие изменять эти поля извне(любые изменения фатальны). Мы установливаем метод так, чтобы нам было легко инициализировать свои Grid-ы, в том числе и текстуры. Наконец, мы обеспечиваем возможность для класса Map, вернуть нам конкретныйй MapGrid, ссылаясь на его координату клетки в мире (которая определяется той же самой парой значений, что и в массиве Grid).

Теперь мы должны выяснить, как мы будем отрисовывать клетки. Есть два способа, которыми мы можем отрисовать изометрическое представление нашего мира, и каждый подход устанавливает ограничения, которые затрагивают остальную часть проекта. Мы можем выбрать неповернутый вид с наклоном, как в классических играх Legend of Zelda and Final Fantasy 2/3 , или мы можем выбрать вид повернутый на 45 градусов с наклоном, как например в Syndicate, Populous и Diablo. Поскольку мы — фанаты истинного изометрического представления, которое повернуто на 45 градусов,то его мы и реализуем, но к сожалению, этот вид более трудный для понимания из двух представленных.

С нормальным представлением мы могли просто выполнять итерации вдоль оси X и сдвигаться вниз вдоль ось Y:


Это привело бы к ожидаемому отображению текстур, накладывающихся предсказуемым и желательным образом, слева направо и сверху вниз . Однако, мы хотим немного другого — мы хотим повернутой представление. В результате, мы хотим видеть :


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


Мы выполняем итерации по диагонали через сетку по аналогии с тем, как показано выше, чтобы достигнуть следующего:


Делая немного по другому , мы в витоге приходим к тому же самому.

Код для того, чтобы выполнить итерации через двумерный массива мы уже видели в нашем Initialize методе выше. Выполнение итераций по диагонали сильно изменяет все. Чтобы понять, как код работает, давайте разберемся с изображением ниже:


Мы хотим идти слева направо, сверху вниз. Это означает что начав с (0,0), двинемся от (0,1) до (1,0), от (0,2) к (2,0), и так далее. Наш код также должен быть достаточно гибким, чтобы обработать различные начальные и конечные координаты, и неквадратные области сетки. Так как мы идем по диагоналям вместо строк, мы должны периписать полностью метод итерации:

public
void Render(SpriteBatch sb,


ref Point CenterGrid,


int DisplayAreaWidth,


int DisplayAreaHeight,


int ScreenWidth,


int ScreenHeight)

{


// this tell us which grid coordinate we’re dealing with


int CurX, CurY;


// these tell us which coordinate limits on each


// axis we are currently processing


int LimitX, LimitY;


// these mark our starting grid for the entire area


int OriginX, OriginY;


// these mark our starting grid for the line


int StartX, StartY;


// these mark our ending grid for the line


int EndX, EndY;


// these get used to know when we finish rendering the desired area


bool bEdgeX, bEdgeY;


// initialize our values to the first grid we’ll process

OriginX = StartX = LimitX = CenterGrid.X — DisplayAreaWidth / 2;

OriginY = StartY = LimitY = CenterGrid.Y — DisplayAreaHeight / 2;


// ensure we aren’t starting outside the bounds of our grid array


if (LimitX < 0) OriginX = StartX = LimitX = 0;


if (LimitY < 0) OriginY = StartY = LimitY = 0;


// initialize our ending grid

EndX = StartX + DisplayAreaWidth — 1;

EndY = StartY + DisplayAreaHeight — 1;


// ensure we aren’t ending outside the bounds of our grid array


if (EndX >= Width) EndX = Width — 1;


if (EndY >= Height) EndY = Height — 1;


while (true)

{


// start a new line

CurX = StartX;

CurY = LimitY;


// reset our edge values

bEdgeX = bEdgeY = false;


// iterate across the line of grids


while ((CurX <= LimitX) && (CurY >= StartY))

{


// move to the next grid on the line

CurX++;

CurY—;

}


// increment our X axis limit


if (++LimitX > EndX)

{


// this means our diagonal traversals are


// hitting the right most edge of the area

LimitX = EndX;


// we’ve also hit the bottom most edge of the array


if (++StartY > EndY)

{

StartY = EndY;

bEdgeY = true;

}

}


// increment our Y axis limit


if (++LimitY > EndY)

{


// this means our diagonal traversals are


// hitting the bottom most edge of the area

LimitY = EndY;


// we’ve also hit the right most edge of the array


if (++StartX > EndX)

{

StartX = EndX;

bEdgeX = true;

}

}


// hitting both edges at the same time


// means we’ve run out of diagonals


if (bEdgeX && bEdgeY) break;

}

Кода, конечно, много и это может запутать, но мы еще не закончили. Приведенный выше код является основной структурой для выполнения итераций по диагонали через наш массив клеток, исходя из средней произвольной клетки (где наша камера или игрок могут быть) и определенной области представления (возможно, мы не хотим рисовать всю карту сразу, только 25 x 25 , центрированных на нашей камере или игроке). Параметры SpriteBatch, ScreenWidth, ScreenHeight — нужны чтобы поддержать фактическое предоставление сетки карты. Прежде, чем мы двинемся далее, мы должны рассказать о фактическом изображении клетки и текстуре.

На иллюстрациях выше, мы видим тили, у которой ширина в два раза больше высоты. Это облегчает понимание смысла глубины при отрисовке других объектов. Мы не обязательно должны использовать размер тилей использованный выше — почти любой размер подойдет. Мы хотим знать, как тили будут выглядеть накладываясь друг на друга. Начнем с создания основной текстуры тилей. Это служит отправной точкой для каждого тиля, они должны все иметь по крайней мере размер основного. Есть много способов, слишком многой, чтобы вдаваться в подробности; один ключевой момент, который надо иметь в виду — то, что мы собираемся видеть большое количество тилей; то есть, в конце концов, природа фрагментации изображения в том, чтобы использовать тили неоднократно.

Теперь у нас есть основной тиль и тиль для пола, и мы должны изменить свой Map.Render() метод, чтобы знать, как отобразить клетку на экран. Мы добьемся этого добавлением двух дополнительных полей в наш Map класс :

public
int FloorTileWidthHalf;

public
int FloorTileHeightHalf;

Может казаться странным, хранить только половину ширины и половину высоты нашего основного тиля, но оказывается, что это гораздо проще и быстрее в вычислительном плане. Стоит отметить, что, в то время как тиль выше имеет размер 34×17, полузначения, мы будем использовать, 16 и 8. Мы выбираем такие значения, так как хотим добиться перекрытия на один пиксель на правой и левой стороне нашего тиля, и на один пиксель снизу. Это и есть результат намеченного наложения тилей.В общем-то это не обязательно и во мноих играх этого не делается.

Теперь мы должны изменить наш Map.Render() метод :

// these mark the rendering location of the first grid,

//serves as a rendering origin for all grids

int SpriteOriginX, SpriteOriginY;

// this is used to store the current grid’s rendering location

Vector2 Sprite;

// this tells us which grid we are currently processing

MapGrid CurGrid;

// this is how we know whether or not we’ve processed

//a grid without having to touch every grid an extra time

ProcessID++;

// add just before the first while loop

// initialize the sprite rendering origin coordinates

SpriteOriginX = (ScreenWidth / 2) +

(ScreenWidth % 2) —

FloorTileWidthHalf;

SpriteOriginY = (ScreenHeight / 2) +

(ScreenHeight % 2) +

FloorTileHeightHalf;

SpriteOriginX -= (CenterGrid.X — StartX) * FloorTileWidthHalf —

(CenterGrid.Y — StartY) * FloorTileWidthHalf;

SpriteOriginY -= (CenterGrid.X — StartX) * FloorTileHeightHalf +

(CenterGrid.Y — StartY) * FloorTileHeightHalf;

// add between the block above and the first while loop

sb.Begin(SpriteBlendMode.AlphaBlend,

SpriteSortMode.Immediate,

SaveStateMode.None);

// inside the second while loop

// this is how we flag the grid as being processed

CurGrid.ProcessID = ProcessID;

// if there’s no sprite associated with this grid, continue on

if (CurGrid.Texture == null)

{

CurX++;

CurY—;


continue;

}

// set our sprite rendering coordinates

Sprite.X = SpriteOriginX +

(CurX — OriginX) * FloorTileWidthHalf —

(CurY — OriginY) * FloorTileWidthHalf +

FloorTileWidthHalf —

CurGrid.Texture.Width / 2;

Sprite.Y = SpriteOriginY +

(CurX — OriginX) * FloorTileHeightHalf +

(CurY — OriginY) * FloorTileHeightHalf —

CurGrid.Texture.Height;

// draw this grid

sb.Draw(CurGrid.Texture, Sprite, Color.White);

// right before we return from the method entirely

sb.End();

Обратим внимание на ProcessID — пора вернутся к нему. Помните, как мы выполняем итерации по каждой клетке в области, которую мы хотим отрисовать? Сейчас, мы обращаемся к каждой клетке только однажды, и это идеально. Однако, мы собираемся ввести код, который может отрисовать или обработать клетку не раз, но это — то, чего мы хотим избежать. Как мы можем проверить, обращались ли мы к клетке или нет? Мы могли бы использовать логическую переменную как флажок, но тогда мы должны будем очистить тот флажок прежде, чем мы сделали какую-либо обработку вообще, и это может существенно замедлить процесс, особенно когда мы рисуем много клеток сразу. Хорошая бы использовать счетчик, обычно int или uint. Этот счетчик увеличивается в начале. Когда мы обращаемся к клетке, мы можем проверить ее внутренний счетчик, и если он не соответствует нашему,то мы можем понять, что мы не отрисовали или не обрабатывали эту клетку для данного кадра. Мы хотим обрабатыватьм клетку только однажды, и к этому стремимся.

Може возникнуть вопрос: «Что, если мы исчерпываем числа для счетчика?». Правильное беспокойство, давайте проверим.

uint в C # (и таким образом XNA) является 32-разрядным (4-байтовым) значением, положительным. Это означает, что мы получаем полный диапазон чисел, которые могут дать 32 бита — 2 в 32-ой степени. Количесво чисел, которые может выразить uint, поэтому 2^32 или 4,294,967,296. Почти 4.3 миллиарда. Достаточно ли этого? Если мы должны изобразить 1000 кадров в секунду без остановок,начав с 0 и добавляя 1 для каждого кадра , мы достигнем значения 2^32 в течение 4 294 967 секунд . 4.3 миллиона секунд это более чем 71 000 минут или почти 1200 часа или почти 50 дней. 50 дней рисования по 1000 кадров в секунду прежде, чем нужно будет волноваться о нашем счетчике uint,который перескочит назад к 0. Если мы ограничимся только 60-ю кадрами в секунду, мы получаем 828 дней — более чем 2 года! Можно не волноваться.

Следующие элементы, которые могут нас запутать — линии, где мы назначаем переменные SpriteOriginX, SpriteOriginY, и Sprite. SpriteOriginX и SpriteOriginY хранят координаты левого нижнего угла клетки, которую мы будем отрисовывать. Это важно, поскольку это дает нам место,от которого мы можем начинать выполнять итерации по нашей области сетки. Мы храним в Sprite фактическое местоположение рисунка текущей клетки, которую мы обрабатываем, смещая от SpriteOriginX/Y используя различия в координатах клетки и половинных размеров тиля.

Если бы мы остановаились здесь, то оказалось, что мы можем отрисовать область любого размера, состоящую из одноклеточных тилей и объектов. Три четверти нашей работы позади. Осталось выяснить, как обработать и отрисовать многоклеточные объекты . Многоклеточные объекты не могут быть отрисованы на той клетке на которых у них начало. Давайте исследовать проблему более близко.


Выше изображены три объекта «дерево»(однгоклеточного) на различных клетках. Поскольку древесный объект — только одна клетка (ширина 1 и высота 1), они должным образом накладывается на ранее отрисованные клетки и объекты. Мы хотим что бы многоклеточные объекты выглядели так:


Должным образом не обрабатывая многоклеточные объекты, мы вместо этого получили бы:


Очевидно, что это неверно. Проблема состоит в том, что мы хотим так или иначе ждать, чтобы не отрисовать объект до всего, что могло на него наложиться. Прежде, чем мы запустим новые функциональные возможности, мы должны добавить новый класс — MapObject.

class MapObject

{


public Point Location;


public Map Map;


public
int Width;


public
int Height;


public Texture2D Texture;

}

Не надо вдаваться в подробности, чтобы понять, что у нас есть базовый класс для всех объектов, которые могут появиться на клетке в нашем игровом мире. Это позволяет нам стандартизировать объекты таким образом,что их представление становится непротиворечивым и простым. Вам может быть любопытным, почему у нас есть экземпляр объекта Map. Это сделано для поддержания будущей функциональности: когда игорок движется между разными картами. Мы также должны изменить MapGrid, добавив экземпляр MapObject, который находится на ней (если есть) через:

public MapObject Object;

Прежде, чем мы сделаем изменения в нашем классе Map, давайте обсудим подробно наше решение для многоклеточных. Мы хотим знать, когда безопасно отрисовать наш многоклеточный объект. Зная, что мы рисуем слева направо, сверху вниз, и также зная, что мы «базируем» весь MapObjects по левому верхнему углу, мы можем безопасно сделать некоторые предположения. Первое, и возможно самое важное, предположение — все клетки под нашим многоклеточным объектом, самые первые клетки, которые будут обработаны. Так же понятно, что последней отрисованной клеткой будет нижняя правая.

Нам нужен быстрый метод, поэтому мы будем стремиться обращаться к каждой клетке как можно меньше(в идеале однажды). Визуальная точность составляет уравнение к надлежащему перекрытию и архитектурами и объектами на экране, так, чтобы вещи в общем правом просмотре. Давайте рассмотрим нашу сетку , чтобы понять, что мы должны задерживать.

Если мы сосредоточимся на том, чтобы все перекрытие происходит слева направо,сверху вниз,то полоучим следующее:


Покрашенные в красный клетки показывают, какие клетки мы должны были бы потенциально задержать, чтобы гарантировать, что все клетки будут отрисованы слева направо. Это плохо, но надо иметь в виду, что у нас может быть много многоклеточных объектов, которые тоже накладываются друг на друга и должны задерживать отрисовку клеток. Поскольку мы рисуем слева направо, любая клетка правее и ниже клетки нашего объекта должна быть отрисована после того, как мы решили, что безопасно отрисовывать наш объект. Это также означает,что другие объекты справа должны быть тоже задержаны. Есть альтернатива:


Вместо проверок, что все наложения осуществлены должным образом, мы сосредотачимся на том, чтобы выделить те клетки, которые могут неверно перекрыть клетки объектов. Это означает, что мы будем заботиться о клетках, которые могли бы перекрыть вершину нашего объекта, если бы мы отрисовывали объекты не по порядку. Чтобы принять меры, мы сделаем очень важное наблюдение:


Светло-фиолетовые клетки показывают визуальные границы нашего объекта. Эти клетки особенные: они определяют границу между клетками которые не перекрываются с нашим объектом и теми которые перекрываются. Теперь, когда обе граничные клтки были обработаны, безопасно рисовать наш объект и клетки под ним, мы сначала должны нарисовать все клетки под объектом, а потом и сам объект.

Осуществление задержанной отрисовки объектов требует добавления большого количества кода к нашему классу Map. Исходный код доступен для скачивания ниже и мы не будем приводить его здесь построчно. Стоит разве что отметить, в коде проведена оптимизация: мы используем ObjectPool, который убирает «мусор».

После выполнения кода , наши результаты выглядят отлично:


Мы получаем 60 FPS, для карты 50×50 . Из-за очень ограниченного использования переменных с плавающей точкой, наш метод можно использовать даже XBox 360.

Исходная стать: http://www.ziggyware.com/readarticle.php?article_id=244

Исходный код: http://www.ziggyware.com/ZiggywareImages/contest/isoEngine/IsometricRenderer.zip

Реклама
Запись опубликована в рубрике Uncategorized. Добавьте в закладки постоянную ссылку.

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

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s