четверг, 3 июня 2010 г.

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

Представляю вашему вниманию очередной вольный перевод главы из книги AdvancED ActionScript 3.0 Animation, автор Keith Peters.

Сегодня речь пойдет об изометрической проекции.
Вот кое-что из того чему мы сегодня научимся

[SWF]http://coolisee.com/wordpress/wp-content/uploads/2010/06/03/iso/GraphicTest.swf, 600, 400[/SWF]
Кликайте по узлам сетки, чтобы построить в них домики




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



Изометрическая проекция – технология, которая стала применяться в компьютерных играх в начале 80-х годов. Это быстрая и эффективная симуляция трехмерного пространства, которая дает иллюзию глубины без большого количества дорогостоящих вычислений. Раньше большинство игр имели вид сверху или вид сбоку. Игры Zaxxon (см рисунок 1) и Qbert (см рисунок 2) стали первыми играми, которые использовали изометрию.

Zaxxon
Рисунок 1. – Zaxxon



Qbert
Рисунок 2. – Qbert



Сейчас, несмотря на развитие 3D технологий в шутерах от первого лица, таких как Halo, игры с изометрическим видом все еще очень популярны, особенно ролевые и стратегии.
Для понимания изометрической проекции, сначала разберем само понятие проекция и чем изометрическая проекция отличается от других способов представления трехмерного пространства.

Проекция отображает трехмерный объект или сцену на двухмерную поверхность, такую как листок бумаги или монитор компьютера. Принцип проекции применяется в фотокамере, которая использует линзу для создания проекции объекта на кадр пленки или электронный сенсор. Даже человеческий глаз проецирует изображение на сетчатку.
На экране или листке бумаги можно получить различные виды проекции. Наиболее используемая это перспективная проекция. В этом типе проекции объекты по мере удаления от точки, в которой располагается глаз наблюдателя, к линии горизонта становятся все меньше и меньше. Этот тип проекции по умолчанию используется во многих 3D библиотеках, таких как Papervision3D и др. См рисунок 2а.

Виды перспективной проекции
Рисунок 2а. – Виды перспективной проекции



Изометрическая проекция в отличие от перспективной является видом аксонометрической проекции. В этом типе проекции соотношение сторон объектов не изменяется при удалении от центра проекции (точки, где располагается глаз наблюдателя).

Слово «изометрическая» в названии проекции означает «равный размер», отражая тот факт, что в этой проекции углы между осями x, y, z равны друг другу и составляют 120 градусов. Это можно увидеть на рисунке 3.

Оси
Рисунок 3. – Изобразим оси как угол комнаты, где встречаются стены и пол. В изометрии углы между осями равны между собой и составляют 120 градусов



Изометрические миры в играх почти всегда основаны на плитках, таким образом, у нас есть уровень, который составлен из отдельных плиток, а не непрерывное, бесшовное пространство. Объекты в мире – это сами плитки или расположены в этих плитках. В большинстве случаев одни и те же плитки могут многократно использоваться при построении уровня (см рисунок 4), или относительно малое количество разных плиток могут дать целый мир (см рисунок 1).

Одна плитка используется для создания целого уровня
Рисунок 4. – Одна плитка используется для создания целого уровня




Изометрия против диметрии



А сейчас я вам открою страшный секрет: почти каждая игра, движок, рисунок, которые названы изометрическим, таковыми на самом деле не являются; они являются диметрическими, т.е. между тремя осями равны между собой только два угла (см рисунок 5). «Диметрия» в переводе с греческого языка означает «две меры». (Есть еще третий тип аксонометрической проекции, называемый триметрией, в которой все три угла между осями не равны между собой.) Диметрическая проекция не обязательно должна использовать именно те значения углов, которые представлены на рисунке 5, но именно эти значения чаще всего применяются в компьютерных играх и т.д.

Значения углов при диметрической проекции
Рисунок 5. – Значения углов при диметрической проекции



Конечно, эти углы кажутся не такими привлекательными для использования, как углы в 120 градусов. Но есть несколько серьезных оснований для использования этих углов, когда вы работаете с пикселями. Давайте возьмем изображение отдельной плитки в изометрии и диметрии (см рисунок 6).

Плитка в изометрии и диметрии
Рисунок 6. – Плитка в изометрии и диметрии



Верхняя плитка на рисунке представлена в изометрии. Угол составляет 60 градусов. Отношение ширины плитки к ее высоте составляет 1,73:1. Нижняя плитка – в диметрии. Хотя угол не целое число, зато отношение ширины к высоте 2:1. Это делает создание рисунков для такой системы гораздо проще. Вместо того чтобы создать плитку размером 173х100, мы создадим плитку размером 200х100. Позиционирование таких плиток также становится гораздо проще.
К тому же, диметрический плиточный мир выглядит гораздо лучше. Вы можете увидеть это, если посмотрите на увеличенное изображение плиток (рисунок 6) – изометрическая плитка выглядит несколько оборванной. Это обусловлено тем, что в изометрии на каждое перемещение в 1 пиксель по вертикали, вы должны отложить 1,73 пикселя в горизонтальном направлении. Поскольку вы не можете делить пиксель на части, то перемещение происходит то на один, то на два пикселя. В диметрическом представлении плитки стороны выглядят гладкими.


Создание изометрической графики



Я думаю будет полезно узнать, как создавать графику для изометрических систем. Для начала сделаем одну плитку.

Во-первых, откройте Flash, Fireworks или Photoshop или любую другую программу для создания графики. Нарисуйте квадрат размером 100х100.

Теперь поверните его на 45 градусов и уменьшите его (масштабируйте) на 50% по вертикали, при этом оставив 100% по горизонтали.

У вас должно получиться в точности так же, как на нижней части рисунка 6. Размер этой фигуры составляет 141.4х70.7, что составляет отношение 2:1.
Теперь, если вы скажете вашим дизайнерам, что они должны сделать все графические символы размером 141.4х70.7, то они вряд ли обрадуются. Поэтому выберите объект и измените его высоту до 100 пикселей, а ширину до 200 пикселей. Такой размер осчастливит ваших дизайнеров. Конечно, вы можете использовать любой размер для вашей плитки, главное, чтобы соблюдалось соотношение между шириной и высотой 2:1. На практике, вы можете сделать размер плитки как можно меньше для лучшей детализации мира и более точного определения столкновений между объектами.

Теперь вы можете заполнить эту плитку любыми графическими символами: это может быть трава, вода, лес, камень и т.д. В идеале графические символы должны создавать иллюзию бесшовного пространства. Если вы хотите поместить плитку на определенную высоту, то это можно сделать как на рисунке 7. В сети есть множество материала по отрисовке графики в изометрии. Поиск по ключевым словам «isometric art tutorials» и «isometric pixel art» должен помочь. Мы же вернемся к программированию.


Рисунок 7




Изометрические преобразования



Одна из самых важных (и возможно наименее понятных, наиболее запутанных) тем в создании изометрических миров – как преобразовать координаты x, y, z изометрического мира в координаты x, y, z экрана и наоборот.

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


Преобразование координат мира в координаты экрана



Во-первых, давайте посмотрим на фактические преобразования, которые дадут точное представление об изометрических преобразованиях и помогут впоследствии создать более быстрый метод. Я создал swf файл для демонстрации преобразований, называется он IsoProjection.swf и его можно найти в исходниках.
Когда вы откроете этот файл, вы увидите куб в трех видах. См рисунок 8


Рисунок 8



Этот swf также показывает координатную систему, которую мы будем использовать: ось х идет слева направо, ось у – сверху вниз, ось z – из экрана в сторону наблюдателя, представляя глубину. Первое преобразование это поворот на 45 градусов относительно оси у. Используйте ползунки и клавиши «влево» и «вправо» для изменения угла. Первое преобразование представлено на рисунке 9.

Первое преобразование: поворот на 45 градусов относительно оси у
Рисунок 9. – Первое преобразование: поворот на 45 градусов относительно оси у



Второе преобразование – это поворот на 30 градусов относительно оси х. Снова используйте те же клавиши для изменения угла. Результат представлен на рисунке 10.

Второе преобразование: поворот относительно оси х на 30 градусов
Рисунок 10. – Второе преобразование: поворот относительно оси х на 30 градусов



Теперь вы видите, что верхняя и нижняя грани куба той же формы, что и фигура, нарисованная на рисунке 6. Эти формы имеют соотношение ширины к высоте 2:1.
Псевдокод этих преобразований следующий:

sX = x * cos(45) – z * sin(45);
z1 = z * cos(45) + x * sin(45);
sY = y * cos(-30) – z1 * sin(-30);
z2 = z1 * cos(-30) + y * sin(-30);


Здесь мы берем координаты x, y, z точки трехмерного пространства и вычисляем координаты sX и sY, которые определяют эту же точку на экране. Эти выражения представляют вращение точки вокруг оси у на 45 градусов и вокруг оси х на – 30 градусов. Строчка, где вычисляется z2 не всегда нужна, но понадобится дальше, когда мы будем делать сортировку по глубине.

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

Более серьезная проблема – это метод дает нам те самые необычные значения размеров плитки. Помните, как мы создавали квадрат размером 100х100? И как после вращения и масштабирования он стал размером 141.4х70.7? То же самое происходит в этом коде. После всех преобразований квадрат размером 100х100 станет формой размером 141.4х70.7.

Мы помним, что мы изменяли это точное значение на 200х100. Это значение получается умножением ширины и высоты на множитель равный 1.414, что равняется квадратному корню из 2. Я оставлю решать вам, что вас интересует. Если нам нужно отмасштабировать нашу графику как здесь, то нам нужно это сделать в коде.

Когда мы упростим наши выражения, мы получим следущее:

sx = x - z;
sy = y * 1.2247 + (x + z) * 0.5;


Если вам интересно как это получилось, то смотрим дальше. Если вы мне доверяете, то можно переходить к следующему разделу.
Начнем с формул, которые дают координаты после вращения:

x1 = x * cos(45) – z * sin(45);
z1 = z * cos(45) + x * sin(45);
y1 = y * cos(-30) – z1 * sin(30);
z2 = z1 * cos(-30) + y * sin(30);


Подставляя числовые значения тригонометрических функций, получим следующее:

x1 = x * 0.707 – z * 0.707;
z1 = z * 0.707 + x * 0.707;
y1 = y * 0.866 – z1 * -0.5;
z2 = z1 * 0.866 + y * -0.5;


Выносим общие множители за скобки:

x1 = (x - z)*0.707;
z1 = (x + z) * 0.707;
y1 = y * 0.866 – z1 * -0.5;
z2 = z1 * 0.866 + y * -0.5;


Подставляем в формулы у1 и z2 вместо z1 его выражение:

x1 = (x - z) * 0.707;
y1 = y * 0.866 – ((x + z) * 0.707) * -0.5;
z2 = (x + z) * 0.707) * 0.866 + y * -0.5;


Делим выражения координат на 0.707:

x1 = x – z;
y1 = y * 1.2247 + (x + z) * 0.5;
z2 = (x + z)*0.866 – y * 0.707;


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

Теперь у нас есть методы, которые проще тех, что у нас были в самом начале.

Некоторые реализации опускают значение 1.2247 и представляю координаты следующим образом:

x1 = x – z;
y1 = y + (x + z) * 0.5;


Это прекрасно работает, когда все объекты на сцене равны между собой по высоте. Кроме того, в простых системах все объекты находятся на одном уровне, поэтому у = 0 и его можно тоже опустить, тогда выражения примут следующий вид.

x1 = x – z;
y1 = (x + z) * 0.5;


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


Преобразование координат экрана в координаты мира



Теперь нам нужно решить обратную задачу: преобразование координат экрана в координаты изометрического мира. Здесь есть небольшая проблема, т.к. координаты экрана представляют два измерения, а нам нужно три. В большинстве случаев нам это нужно, чтобы соотнести координаты точки, по которой кликнули мышью на экране, в координаты этой же точки, только уже в трехмерном мире. Поэтому, то, что нам действительно нужно – это преобразовать экранные координаты x и y в мировые x и z, при высоте y равной нулю. Из экранных координат:

sx = x – z;
sy = y * 1.2247 + (x + z) * 0.5;


при у=0, мы получим следующее:

x = sy + sx / 2;
y = 0;
z = sy – sx / 2;



Класс IsoUtils



Достаточно теории и псевдокода. Посмотрим на реальный код и проверим его в действии. Для начала нам потребуется структура, представляющая точку в трехмерном пространстве. Класс 3D:

package com.friendsofed.isometric
{
public class Point3D
{
public var x:Number;
public var y:Number;
public var z:Number;

public function Point3D(x:Number = 0, y:Number = 0, z:Number = 0)
{
this.x = x;
this.y = y;
this.z = z;
}
}
}


Теперь класс IsoUtils, который преобразовывает координаты экрана в координаты мира и наоборот:

package com.friendsofed.isometric
{
import flash.geom.Point;

public class IsoUtils
{
// более точное значение 1.2247...
public static const Y_CORRECT:Number = Math.cos(-Math.PI / 6) * Math.SQRT2;

/**
* Из трехмерного пространства в двухмерное.
* @arg pos точка трехмерного пространства.
*/
public static function isoToScreen(pos:Point3D):Point
{
var screenX:Number = pos.x - pos.z;
var screenY:Number = pos.y * Y_CORRECT + (pos.x + pos.z) * .5;
return new Point(screenX, screenY);
}

/**
* Из двухмерного пространства в трехмерное, высота у равна нулю.
* @arg point точка в двухмерном пространстве.
*/
public static function screenToIso(point:Point):Point3D
{
var xpos:Number = point.y + point.x * .5;
var ypos:Number = 0;
var zpos:Number = point.y - point.x * .5;
return new Point3D(xpos, ypos, zpos);
}

}
}


Заметим, что значение 1.2247 теперь вычисляется с помощью тригонометрических функций. Точность и аккуратность!

А теперь общее представление как использовать эти методы:

package
{
import com.friendsofed.isometric.IsoUtils;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.geom.Point;

public class IsoTransformTest extends Sprite
{
public function IsoTransformTest()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

var p0:Point3D = new Point3D(0, 0, 0);
var p1:Point3D = new Point3D(100, 0, 0);
var p2:Point3D = new Point3D(100, 0, 100);
var p3:Point3D = new Point3D(0, 0, 100);

var sp0:Point = IsoUtils.isoToScreen(p0);
var sp1:Point = IsoUtils.isoToScreen(p1);
var sp2:Point = IsoUtils.isoToScreen(p2);
var sp3:Point = IsoUtils.isoToScreen(p3);

var tile:Sprite = new Sprite();
tile.x = 200;
tile.y = 200;
addChild(tile);

tile.graphics.lineStyle(0);
tile.graphics.moveTo(sp0.x, sp0.y);
tile.graphics.lineTo(sp1.x, sp1.y);
tile.graphics.lineTo(sp2.x, sp2.y);
tile.graphics.lineTo(sp3.x, sp3.y);
tile.graphics.lineTo(sp0.x, sp0.y);
}

}
}


Сначала создаем четыре экземпляра класса Point3D, формируем из них квадрат в плоскости x-z. Затем используем статический метод IsoUtils.isoToScreen() для преобразования трехмерных точек в двухмерные. Затем создаем спрайт и добавляем его в список отображения. Соединяем в нем наши двухмерные точки и видим при запуске swf нашу старую знакомую плитку. Если вы выведете через trace значения ширины и высоты спрайта tile, то увидите, что они составляют 200 и 100 соответственно.
Конечно, таким способом не нарисуешь богатый и красивый изометрический мир. Эти методы используются для позиционирования объектов в мире. Наш следующий шаг – создание класса для представления изометрических объектов.


Изометрические объекты



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

package com.friendsofed.isometric
{
import flash.display.Sprite;
import flash.geom.Point;
import flash.geom.Rectangle;

public class IsoObject extends Sprite
{
protected var _position:Point3D;
protected var _size:Number;
protected var _walkable:Boolean = false;
protected var _vx:Number = 0;
protected var _vy:Number = 0;
protected var _vz:Number = 0;

public static const Y_CORRECT:Number = Math.cos(-Math.PI / 6) * Math.SQRT2;

public function IsoObject(size:Number)
{
_size = size;
_position = new Point3D();
updateScreenPosition();
}

/**
* Преобразование трехмерной позиции объекта в двухмерную
* и его размещение на экране.
*/
protected function updateScreenPosition():void
{
var screenPos:Point = IsoUtils.isoToScreen(_position);
super.x = screenPos.x;
super.y = screenPos.y;
}

/**
* Строковое представление объекта.
*/
override public function toString():String
{
return "[IsoObject (x:" + _position.x + ", y:" + _position.y + ", z:" + _position.z + ")]";
}

/**
* Сеттер/геттер координаты х в трехмерном пространстве.
*/
override public function set x(value:Number):void
{
_position.x = value;
updateScreenPosition();
}
override public function get x():Number
{
return _position.x;
}

/**
* Сеттер/геттер координаты у в трехмерном пространстве.
*/
override public function set y(value:Number):void
{
_position.y = value;
updateScreenPosition();
}
override public function get y():Number
{
return _position.y;
}

/**
* Сеттер/геттер координаты z в трехмерном пространстве.
*/
override public function set z(value:Number):void
{
_position.z = value;
updateScreenPosition();
}
override public function get z():Number
{
return _position.z;
}

/**
* Сеттер/геттер позиции в трехмерном пространстве как экземпляра Point3D.
*/
public function set position(value:Point3D):void
{
_position = value;
updateScreenPosition();
}
public function get position():Point3D
{
return _position;
}

/**
* Возвращает глубину объекта.
*/
public function get depth():Number
{
return (_position.x + _position.z) * .866 - _position.y * .707;
}

/**
* Указывает, может ли место, занятое этим объектом, быть занято другим объектом.
*/
public function set walkable(value:Boolean):void
{
_walkable = value;
}
public function get walkable():Boolean
{
return _walkable;
}

/**
* Возвращает размер объекта
*/
public function get size():Number
{
return _size;
}

/**
* Возвращает квадратную область на x-z плоскости, которую занимает этот объект.
*/
public function get rect():Rectangle
{
return new Rectangle(x - size / 2, z - size / 2, size, size);
}

/**
* Сеттер/геттер скорости по оси х.
*/
public function set vx(value:Number):void
{
_vx = value;
}
public function get vx():Number
{
return _vx;
}

/**
* Сеттер/геттер скорости по оси у.
*/
public function set vy(value:Number):void
{
_vy = value;
}
public function get vy():Number
{
return _vy;
}

/**
* Сеттер/геттер скорости по оси z.
*/
public function set vz(value:Number):void
{
_vz = value;
}
public function get vz():Number
{
return _vz;
}
}
}


Большая часть этого класса занимается получением и записью 3D позиции объекта и вычислением его экранных координат. Т.к. класс расширяет Sprite, экранная позиция может быть установлена вызовом super.x и super.y. Заметим, что метод updateScreenPosition() использует статический метод IsoUtils.isoToScreen().

Если вы будете часто использовать этот класс, то вот предложение, которое вы, возможно, захотите осуществить. Метод updateScreenPosition() вызывается каждый раз, когда изменяются координаты x, y, z, что не совсем эффективно. Это часто обходится с помощью метода инвалидации, который помечает объект, как нуждающийся в обновлении и устанавливает слушатель события enterFrame, чтобы обновить объект в следующем кадре. Таким образом, вы можете обновлять координаты x, y, z 100 раз, а экранную позицию только, когда вам это нужно.

Другие методы – такие как геттеры/сеттеры глубины, проходимости и области – используются главным образом при сортировке по глубине и проверке на пересечение.
Заметим, что конструктор принимает только один параметр – size. Все экземпляры класса IsoObject имеют свойство size, которое определяет занимаемую ими площадь на плоскости x-z трехмерного мира. Эта площадь может быть только квадратной, что имеет смысл в плиточном мире. Более подробно мы об этом поговорим позже.
Сейчас у класса IsoObject нет визуального представления. Мы могли бы что-нибудь нарисовать вручную, но давайте создадим новый класс:

package com.friendsofed.isometric
{
public class DrawnIsoTile extends IsoObject
{
protected var _height:Number;
protected var _color:uint;

public function DrawnIsoTile(size:Number, color:uint, height:Number = 0)
{
super(size);
_color = color;
_height = height;
draw();
}

/**
* Рисуем плитку.
*/
protected function draw():void
{
graphics.clear();
graphics.beginFill(_color);
graphics.lineStyle(0, 0, .5);
graphics.moveTo(-size, 0);
graphics.lineTo(0, -size * .5);
graphics.lineTo(size, 0);
graphics.lineTo(0, size * .5);
graphics.lineTo(-size, 0);
}

/**
* Сеттер/геттер высоты этого объекта. Не используется в этом классе, но может использоваться в подклассах.
*/
override public function set height(value:Number):void
{
_height = value;
draw();
}
override public function get height():Number
{
return _height;
}

/**
* Сеттер/геттер цвета этой плитки.
*/
public function set color(value:uint):void
{
_color = value;
draw();
}
public function get color():uint
{
return _color;
}
}
}


Как вы видите, этот класс принимает в конструкторе значения color и height, которые хранятся в свойствах класса. Есть сеттеры/геттеры для этих двух свойств. Хотя значение высоты не используется в этом классе, оно может использоваться в подклассах, которые будут представлять разные плитки.

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

package
{
import com.friendsofed.isometric.DrawnIsoTile;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;

[SWF(backgroundColor=0xffffff)]
public class TileTest extends Sprite
{
public function TileTest()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

var world:Sprite = new Sprite();
world.x = stage.stageWidth / 2;
world.y = 100;
addChild(world);

for(var i:int = 0; i < 20; i++)
{
for(var j:int = 0; j < 20; j++)
{
var tile:DrawnIsoTile = new DrawnIsoTile(20, 0xcccccc);
tile.position = new Point3D(i * 20, 0, j * 20);
world.addChild(tile);
}
}
}

}
}


Этот код сначала создает спрайт world, который содержит все плитки и позволяет использовать их как одно целое. Затем этот спрайт перемещается в центр сцены.
Затем запускается двойной цикл. В теле вложенного цикла создается новый DrawnIsoTile с размером равным 20 и цветом по умолчанию. Его позиция устанавливается в координатах x и z с множителями I и j и он добавляется в спрайт. Когда цикл завершится, мы увидим следующую картину:


Рисунок 11



Это – основа плиточного мира. Вы можете с этим немного поэкспериментировать. Заметим, что изменение количества циклов даст нам изменение плиточного мира. Т.к. размер плитки 20 и каждая из них располагается через каждые 20 точек по осям x и z, то они плотную поверхность.

Плитки это хорошо, но давайте пойдем немного дальше и создадим 3D изометрические блоки с переменной высотой. Если вы занимались 3D моделированием, то наверняка слышали о освещении. В изометрическом блоке у нас есть три видимых грани. Для создания иллюзии пространства, каждая из них должна быть оттенена немного по-разному. В нашем классе DrawnIsoBox предполагается, что источник света находится справа, поэтому правая грань будет более яркая, чем левая:

package com.friendsofed.isometric
{
public class DrawnIsoBox extends DrawnIsoTile
{

public function DrawnIsoBox(size:Number, color:uint, height:Number)
{
super(size, color, height);
}

override protected function draw():void
{
graphics.clear();
var red:int = _color >> 16;
var green:int = _color >> 8 & 0xff;
var blue:int = _color & 0xff;

var leftShadow:uint = (red * .5) << 16 |
(green * .5) << 8 |
(blue * .5);
var rightShadow:uint = (red * .75) << 16 |
(green * .75) << 8 |
(blue * .75);

var h:Number = _height * Y_CORRECT;
// draw top
graphics.beginFill(_color);
graphics.lineStyle(0, 0, .5);
graphics.moveTo(-_size, -h);
graphics.lineTo(0, -_size * .5 - h);
graphics.lineTo(_size, -h);
graphics.lineTo(0, _size * .5 - h);
graphics.lineTo(-_size, -h);
graphics.endFill();

// draw left
graphics.beginFill(leftShadow);
graphics.lineStyle(0, 0, .5);
graphics.moveTo(-_size, -h);
graphics.lineTo(0, _size * .5 - h);
graphics.lineTo(0, _size * .5);
graphics.lineTo(-_size, 0);
graphics.lineTo(-_size, -h);
graphics.endFill();

// draw right
graphics.beginFill(rightShadow);
graphics.lineStyle(0, 0, .5);
graphics.moveTo(_size, -h);
graphics.lineTo(0, _size * .5 - h);
graphics.lineTo(0, _size * .5);
graphics.lineTo(_size, 0);
graphics.lineTo(_size, -h);
graphics.endFill();
}
}
}


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

var h:Number = _height * Y_CORRECT;


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

sx = x – z;
sy = y * 1.2247 + (x + z) * 0.5;


Я уже упоминал, что в некоторых реализациях значение 1.2247 опускается и эти реализации работают.
Конечно, теперь нам нужен пример использования класса DrawnIsoBox. Самое легкое это изменить в классе TileTest имя создаваемого объекта в цикле, но это совсем не интересно, т.к. высота всех блоков по умолчанию равна нулю и они будут выглядеть как плитки. Вы можете изменить высоту, но тогда у нас будет просто толстая плоскость. Сделаем что-нибудь более интересное: используем метод screenToIso класса IsoUtils для захвата кликов мыши и добавления блока в точку по которой этот клик был сделан. Итак класс BoxTest:

package
{
import com.friendsofed.isometric.DrawnIsoBox;
import com.friendsofed.isometric.DrawnIsoTile;
import com.friendsofed.isometric.IsoUtils;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.events.MouseEvent;
import flash.geom.Point;

[SWF(backgroundColor=0xffffff)]
public class BoxTest extends Sprite
{
private var world:Sprite;
public function BoxTest()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

world = new Sprite();
world.x = stage.stageWidth / 2;
world.y = 100;
addChild(world);

for(var i:int = 0; i < 20; i++)
{
for(var j:int = 0; j < 20; j++)
{
var tile:DrawnIsoTile = new DrawnIsoTile(20, 0xcccccc);
tile.position = new Point3D(i * 20, 0, j * 20);
world.addChild(tile);
}
}

world.addEventListener(MouseEvent.CLICK, onWorldClick);
}

private function onWorldClick(event:MouseEvent):void
{
var box:DrawnIsoBox = new DrawnIsoBox(20, Math.random() * 0xffffff, 20);
var pos:Point3D = IsoUtils.screenToIso(new Point(world.mouseX, world.mouseY));
pos.x = Math.round(pos.x / 20) * 20;
pos.y = Math.round(pos.y / 20) * 20;
pos.z = Math.round(pos.z / 20) * 20;
box.position = pos;
world.addChild(box);
}

}
}


Большая часть класса похожа на TileTest. После того как мы создаем сетку из плиток, мы добавляем слушатель события MouseEvent.CLICK к спрайту нашего мира. Когда происходит клик по спрайту, мы создаем новый блок и даем ему случайный цвет. Затем мы получаем координаты мыши и преобразуем их в трехмерные координаты. Наконец, мы добавляем блок в наш мир.

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

		private function onWorldClick(event:MouseEvent):void
{
var box:DrawnIsoBox = new DrawnIsoBox(20, Math.random() * 0xffffff, 20);
var pos:Point3D = IsoUtils.screenToIso(new Point(world.mouseX, world.mouseY));
pos.x = Math.round(pos.x / 20) * 20;
pos.y = Math.round(pos.y / 20) * 20;
pos.z = Math.round(pos.z / 20) * 20;
box.position = pos;
world.addChild(box);
}


Теперь, если вы кликнете по любой плитке, то на ней появиться блок, как это представлено на рисунке 12.


Рисунок 12. – Блоки на сетке из плиток



Возможно, вы обратили внимание на некоторые проблемы, если экспериментировали с этим классом. Наиболее вероятно, что эти проблемы имели отношение к сортировке по глубине, о которой мы сейчас поговорим.


Сортировка глубины



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

Проблемы с глубиной объектов
Рисунок 13. – Проблемы с глубиной объектов



Это происходит потому, что метод addChild спрайта world добавляет нового ребенка поверх остальных объектов в списке отображения. Если вы добавляете блоки слева направо и сверху вниз, то все работает прекрасно. Два блока справа на рисунке 13 были отрисованы этим способом. Блоки слева были отрисованы справа налево, так второй блок скрывает одну из граней первого, т.к. метод addChild добавил его выше, чем первый блок. Чтобы исключить эту ситуацию, нам нужно определить глубину для каждого объекта в мире и сортировать список отображения согласно этому определению глубины.

Существует множество методов сортировки по глубине (z-сортировки) и на различных форумах происходят целые эпические сражения в защиту тех или иных методов.
Мне кажется, что проблема может быть решена простым математическим способом. Вспомним формулы для преобразования координат:

x1 = x – z;
y1 = y * 1.2247 + () * 0.5;
z2 = (x + z) * 0.866 – y * 0.707;


х1 и у1 преобразовываются в х и у и используются для определения позиции точки или объекта на экране. z2 – преобразованная координата по оси z, которую теперь самое время использовать для сортировки глубины, потому, что эта переменная дает нам расстояние от точки, где располагается глаз наблюдателя.
Посмотрим на следующий метод класса IsoObject:

		/**
* Возвращает глубину объекта.
*/
public function get depth():Number
{
return (_position.x + _position.z) * .866 - _position.y * .707;
}


Как видите, этот метод вычисляет преобразованное значение z.

Суть моего способа сортировки по глубине в том, чтобы поместить все экземпляры IsoObject в массив и затем отсортировать этот массив по значению depth каждого. Теперь мы можем перестроить список отображения в соответствии с положением элементов в массиве. Мы должны будем сортировать массив и перестраивать список отображения каждый раз, когда добавляется объект. Посмотрим на следующий пример:

package
{
import com.friendsofed.isometric.DrawnIsoBox;
import com.friendsofed.isometric.DrawnIsoTile;
import com.friendsofed.isometric.IsoUtils;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.events.MouseEvent;
import flash.geom.Point;

[SWF(backgroundColor=0xffffff)]
public class DepthTest extends Sprite
{
private var world:Sprite;
private var objectList:Array;

public function DepthTest()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

world = new Sprite();
world.x = stage.stageWidth / 2;
world.y = 100;
addChild(world);

objectList = new Array();

for(var i:int = 0; i < 20; i++)
{
for(var j:int = 0; j < 20; j++)
{
var tile:DrawnIsoTile = new DrawnIsoTile(20, 0xcccccc);
tile.position = new Point3D(i * 20, 0.1, j * 20);
world.addChild(tile);
objectList.push(tile);
}
}
sortList();
world.addEventListener(MouseEvent.CLICK, onWorldClick);
}

private function onWorldClick(event:MouseEvent):void
{
var box:DrawnIsoBox = new DrawnIsoBox(20, Math.random() * 0xffffff, 20);
var pos:Point3D = IsoUtils.screenToIso(new Point(world.mouseX, world.mouseY));
pos.x = Math.round(pos.x / 20) * 20;
pos.y = Math.round(pos.y / 20) * 20;
pos.z = Math.round(pos.z / 20) * 20;
box.position = pos;
world.addChild(box);
objectList.push(box);
sortList();
}

private function sortList():void
{
objectList.sortOn("depth", Array.NUMERIC);
for(var i:int = 0; i < objectList.length; i++)
{
world.setChildIndex(objectList[i], i);
}
}
}
}


Здесь мы создаем массив objectList. Каждая плитка и каждый блок добавляется в массив и вызывается метод sortList(). Этот метод сортирует массив по свойству depth каждого элемента. Не стоит забывать задать критерий сортировки Array.NUMERIC иначе будут сортироваться строковые значения свойства depth и окажется, что «70» больше, чем «100».
Единственный недостаток сортировки по такому значению глубины, это то, что все объекты должны иметь одно и тоже значение свойства size класса IsoObject, которое определяет площадь, занимаемую объектом на плоскости x-z. Объекты могут быть любой высоты, но не шире других объектов в мире. Лучший способ создать большие объекты – создать их из нескольких малых стандартного размера.

Последний пример отлично сортирует блоки, независимо от того, в каком порядке вы их размещаете. Однако, иногда плитки располагаются выше блоков. Хотя блоки и располагаются выше плиток, на самом деле у них одинаковые координаты x, y и z – следовательно у них одинаковые значения глубины. С точки зрения сортировки глубины нет никакой разницы, какой элемент будет выше, если их значения глубины одинаковы. Можно устранить эту ошибку двумя способами: поместить плитки немного ниже или блоки немного выше. Это будет незаметно для глаза, но достаточно для корректной сортировки. Сделаем это в следующей строчке:

tile.position = new Point3D(I * 20, 0.1, j * 20);


Другой более сложный (но более эффективный) способ заключается в том, чтобы создать для размещения плиток и блоков разные спрайты – один для плиток и один для блоков. Действительно, не нужно сортировать каждый раз 400 плиток, т.к. они остаются всегда неподвижными. Разместим спрайт с блоками выше спрайта с плитками и будем добавлять в массив objectList только блоки. Смотрим новый класс:

package
{
import com.friendsofed.isometric.DrawnIsoBox;
import com.friendsofed.isometric.DrawnIsoTile;
import com.friendsofed.isometric.IsoUtils;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.events.MouseEvent;
import flash.geom.Point;

[SWF(backgroundColor=0xffffff)]
public class DepthTest2 extends Sprite
{
private var floor:Sprite;
private var world:Sprite;
private var objectList:Array;

public function DepthTest2()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

floor = new Sprite();
floor.x = stage.stageWidth / 2;
floor.y = 100;
addChild(floor);

world = new Sprite();
world.x = stage.stageWidth / 2;
world.y = 100;
addChild(world);

objectList = new Array();

for(var i:int = 0; i < 20; i++)
{
for(var j:int = 0; j < 20; j++)
{
var tile:DrawnIsoTile = new DrawnIsoTile(20, 0xcccccc);
tile.position = new Point3D(i * 20, 0, j * 20);
floor.addChild(tile);
}
}
stage.addEventListener(MouseEvent.CLICK, onWorldClick);
}

private function onWorldClick(event:MouseEvent):void
{
var box:DrawnIsoBox = new DrawnIsoBox(20, Math.random() * 0xffffff, 20);
var pos:Point3D = IsoUtils.screenToIso(new Point(world.mouseX, world.mouseY));
pos.x = Math.round(pos.x / 20) * 20;
pos.y = Math.round(pos.y / 20) * 20;
pos.z = Math.round(pos.z / 20) * 20;
box.position = pos;
world.addChild(box);
objectList.push(box);
sortList();
}

private function sortList():void
{
objectList.sortOn("depth", Array.NUMERIC);
for(var i:int = 0; i < objectList.length; i++)
{
world.setChildIndex(objectList[i], i);
}
}
}
}


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


Класс изометрического мира



Т.к. многое из того, что мы сделали, понадобится нам неоднократно, создадим класс, который можно будет использовать в любом проекте, где потребуется изометрический мир. Это класс IsoWorld:

package com.friendsofed.isometric
{
import flash.display.DisplayObject;
import flash.display.Sprite;
import flash.geom.Rectangle;

public class IsoWorld extends Sprite
{
private var _floor:Sprite;
private var _objects:Array;
private var _world:Sprite;

public function IsoWorld()
{
_floor = new Sprite();
addChild(_floor);

_world = new Sprite();
addChild(_world);

_objects = new Array();
}

public function addChildToWorld(child:IsoObject):void
{
_world.addChild(child);
_objects.push(child);
sort();
}

public function addChildToFloor(child:IsoObject):void
{
_floor.addChild(child);
}

public function sort():void
{
_objects.sortOn("depth", Array.NUMERIC);
for(var i:int = 0; i < _objects.length; i++)
{
_world.setChildIndex(_objects[i], i);
}
}

public function canMove(obj:IsoObject):Boolean
{
var rect:Rectangle = obj.rect;
rect.offset(obj.vx, obj.vz);
for(var i:int = 0; i < _objects.length; i++)
{
var objB:IsoObject = _objects[i] as IsoObject;
if(obj != objB && !objB.walkable && rect.intersects(objB.rect))
{
return false;
}
}
return true;
}
}
}


Многое из этого мы уже видели в предыдущих примерах: создается спрайт уровня, спрайт мира и массив, в котором храниться список объектов. Добавляются два метода для добавления объектов. Метод addChildToFloor() добавляет объекты в спрайт уровня, но не добавляет в массив объектов, поэтому они не участвуют в сортировке. Метод addChildToWorld() добавляет объекты в спрайт мира и в список объектов, сортирует этот список и перестраивает список отображения. Позже добавим в этот класс функциональность, необходимую для проверки на столкновения.

Использовать класс IsoWorld очень просто, посмотрим следующий пример:

package
{
import com.friendsofed.isometric.DrawnIsoBox;
import com.friendsofed.isometric.DrawnIsoTile;
import com.friendsofed.isometric.IsoUtils;
import com.friendsofed.isometric.IsoWorld;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.events.MouseEvent;
import flash.geom.Point;

[SWF(backgroundColor=0xffffff)]
public class WorldTest extends Sprite
{
private var world:IsoWorld;

public function WorldTest()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

world = new IsoWorld();
world.x = stage.stageWidth / 2;
world.y = 100;
addChild(world);

for(var i:int = 0; i < 20; i++)
{
for(var j:int = 0; j < 20; j++)
{
var tile:DrawnIsoTile = new DrawnIsoTile(20, 0xcccccc);
tile.position = new Point3D(i * 20, 0, j * 20);
world.addChildToFloor(tile);
}
}
stage.addEventListener(MouseEvent.CLICK, onWorldClick);
}

private function onWorldClick(event:MouseEvent):void
{
var box:DrawnIsoBox = new DrawnIsoBox(20, Math.random() * 0xffffff, 20);
var pos:Point3D = IsoUtils.screenToIso(new Point(world.mouseX, world.mouseY));
pos.x = Math.round(pos.x / 20) * 20;
pos.y = Math.round(pos.y / 20) * 20;
pos.z = Math.round(pos.z / 20) * 20;
box.position = pos;
world.addChildToWorld(box);
}
}
}


Фактически, этот класс почти идентичен классу BoxTest, однако здесь создаются разные спрайты для хранения уровня и мира отдельно.


Перемещение в 3D



Перемещение в трехмерном пространстве не очень сложная задача, если есть класс IsoObject, который автоматически преобразует трехмерные координаты в двухмерные. Нам нужно просто изменить координаты x, y, z и объект переместится в соответствующую позицию на экране. Только об одной вещи необходимо помнить – необходимо вызывать метод сортировки класса IsoWorld для того, чтобы объект находился на правильной глубине.

Предельно просто реализовать любой вид движения. Введем три новых свойства в класс IsoObject для определения скорости объекта:

protected var _vx:Number = 0;
protected var _vy:Number = 0;
protected var _vz:Number = 0;


И добавим геттеры/сеттеры для них:

                 /**
* Sets / gets the velocity on the x axis.
*/
public function set vx(value:Number):void
{
_vx = value;
}
public function get vx():Number
{
return _vx;
}

/**
* Sets / gets the velocity on the y axis.
*/
public function set vy(value:Number):void
{
_vy = value;
}
public function get vy():Number
{
return _vy;
}

/**
* Sets / gets the velocity on the z axis.
*/
public function set vz(value:Number):void
{
_vz = value;
}
public function get vz():Number
{
return _vz;
}


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

package
{
import com.friendsofed.isometric.DrawnIsoBox;
import com.friendsofed.isometric.DrawnIsoTile;
import com.friendsofed.isometric.IsoWorld;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.events.Event;
import flash.events.KeyboardEvent;
import flash.ui.Keyboard;

[SWF(backgroundColor=0xffffff)]
public class MotionTest extends Sprite
{
private var world:IsoWorld;
private var box:DrawnIsoBox;
private var speed:Number = 5;

public function MotionTest()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

world = new IsoWorld();
world.x = stage.stageWidth / 2;
world.y = 100;
addChild(world);

for(var i:int = 0; i < 20; i++)
{
for(var j:int = 0; j < 20; j++)
{
var tile:DrawnIsoTile = new DrawnIsoTile(20, 0xcccccc);
tile.position = new Point3D(i * 20, 0, j * 20);
world.addChildToFloor(tile);
}
}

box = new DrawnIsoBox(20, 0xff0000, 20);
box.x = 200;
box.z = 200;
world.addChildToWorld(box);

stage.addEventListener(KeyboardEvent.KEY_DOWN, onKeyDown);
stage.addEventListener(KeyboardEvent.KEY_UP, onKeyUp);
}

private function onKeyDown(event:KeyboardEvent):void
{
switch(event.keyCode)
{
case Keyboard.UP :
box.vx = -speed;
break;

case Keyboard.DOWN :
box.vx = speed;
break;

case Keyboard.LEFT :
box.vz = speed;
break;

case Keyboard.RIGHT :
box.vz = -speed;
break;

default :
break;

}
addEventListener(Event.ENTER_FRAME, onEnterFrame);
}

private function onKeyUp(event:KeyboardEvent):void
{
box.vx = 0;
box.vz = 0;
removeEventListener(Event.ENTER_FRAME, onEnterFrame);
}

private function onEnterFrame(event:Event):void
{
box.x += box.vx;
box.y += box.vy;
box.z += box.vz;
world.sort();
}
}
}


Здесь все предельно просто. Уровень плиток располагается ниже уровня блоков. Устанавливаются слушатели для событий нажатия и отпускания клавиши клавиатуры. Если нажата одна из соответствующих клавиш, то изменяются параметры vx и vz. В методе onEnterFrame() скорость блока добавляется к его позиции. Не забывайте производить сортировку глубины после каждого перемещения. Конечно, если объект один, то сортировка не требуется.
Устанавливая скорости значение равное размеру плитки, мы заставим его перемещаться на следующую плитку за одно нажатие клавиши.
Чтобы показать другой тип движения посмотрим следующую демонстрацию:

package
{
import com.friendsofed.isometric.DrawnIsoBox;
import com.friendsofed.isometric.DrawnIsoTile;
import com.friendsofed.isometric.IsoWorld;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.events.Event;
import flash.events.MouseEvent;
import flash.filters.BlurFilter;

[SWF(backgroundColor=0xffffff)]
public class MotionTest2 extends Sprite
{
private var world:IsoWorld;
private var box:DrawnIsoBox;
private var shadow:DrawnIsoTile;
private var gravity:Number = 2;
private var friction:Number = 0.95;
private var bounce:Number = -0.9;
private var filter:BlurFilter;

public function MotionTest2()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

world = new IsoWorld();
world.x = stage.stageWidth / 2;
world.y = 100;
addChild(world);

for(var i:int = 0; i < 20; i++)
{
for(var j:int = 0; j < 20; j++)
{
var tile:DrawnIsoTile = new DrawnIsoTile(20, 0xcccccc);
tile.position = new Point3D(i * 20, 0, j * 20);
world.addChildToFloor(tile);
}
}

box = new DrawnIsoBox(20, 0xff0000, 20);
box.x = 200;
box.z = 200;
world.addChildToWorld(box);

shadow = new DrawnIsoTile(20, 0);
shadow.alpha = .5;
world.addChildToFloor(shadow);

filter = new BlurFilter();

addEventListener(Event.ENTER_FRAME, onEnterFrame);
stage.addEventListener(MouseEvent.CLICK, onClick);
}

private function onClick(event:MouseEvent):void
{
box.vx = Math.random() * 20 - 10;
box.vy = -Math.random() * 40;
box.vz = Math.random() * 20 - 10;
}
private function onEnterFrame(event:Event):void
{
box.vy += 2;
box.x += box.vx;
box.y += box.vy;
box.z += box.vz;
if(box.x > 380)
{
box.x = 380;
box.vx *= -.8;
}
else if(box.x < 0)
{
box.x = 0;
box.vx *= bounce;
}
if(box.z > 380)
{
box.z = 380;
box.vz *= bounce;
}
else if(box.z < 0)
{
box.z = 0;
box.vz *= bounce;
}
if(box.y > 0)
{
box.y = 0;
box.vy *= bounce;
}
box.vx *= friction;
box.vy *= friction;
box.vz *= friction;

shadow.x = box.x;
shadow.z = box.z;
filter.blurX = filter.blurY = -box.y * .25;
shadow.filters = [filter];
}
}
}


Здесь используется сила тяжести, отскок, трение и движение вдоль оси y трехмерного пространства. Когда вы кликнете мышью, блок случайно получит скорость вдоль каждой оси. Если координата блока становится равной нулю по оси у или превысит заданные ограничения по осям х и z, то блок отскакивает назад.
Также я добавил тень в форме DrawnIsoTile с 50% прозрачностью. Она помещена в уровень пола и перемещается по осям x, z вслед за блоком. Также она изменяет свой размер и прозрачность в зависимости от координаты у нашего блока. Не слишком сложно и дает приятную иллюзию.
Все неплохо пока в нашем изолированном мире находится один объект, но что будет, если поместить в этот мир больше объектов. Поговорим об этом в следующем разделе.


Определение столкновений



Чтобы увидеть необходимость определения столкновений создадим в классе MotionTest еще один или два блока:

			box = new DrawnIsoBox(20, 0xff0000, 20);
box.x = 200;
box.z = 200;
world.addChildToWorld(box);


Они не должны двигаться. Теперь переместим первый блок так, чтобы он касался новых блоков. Некрасива, правда? Подвижный блок проходит прямо через неподвижные блоки, которые выглядят при этом ужасно. Как будто сортировка глубины внезапно сломалась. Это происходит, т.к. два объекта пытаются занять одно и то же место в мире.
Чтобы исправить это нам нужен метод, который дает знать, куда объект может двигаться, а куда нет. Поскольку класс IsoWorld содержит сведения о всех объектах этого мира, логично расположить этот метод здесь. Вот этот метод canMove():

		public function canMove(obj:IsoObject):Boolean
{
var rect:Rectangle = obj.rect;
rect.offset(obj.vx, obj.vz);
for(var i:int = 0; i < _objects.length; i++)
{
var objB:IsoObject = _objects[i] as IsoObject;
if(obj != objB && !objB.walkable && rect.intersects(objB.rect))
{
return false;
}
}
return true;
}


Метод берет экземпляр класса IsoObject и дает знать безопасно ли для этого объекта двигаться к той позиции, в которой он бы оказался если бы значения vx и vz добавили к его координатам.

Если вы посмотрите на класс IsoObject, то увидите его свойство rect тип которого flash.geom.Rectangle. Оно представляет площадь, которую занимает объект на плоскости x-z. Мы берем свойство rect этого объекта и перемещаем его на значения vx и vz. Это свойство rect теперь представляет площадь объекта занимаемую им после следующего перемещения.

Теперь мы запускаем цикл, в котором перебираем все объекты из списка объектов. Здесь мы проверяем три условия:
- первое, то, что объект не проверяется на пересечения сам с собой;
- второе, является ли пересекаемый объект непроходимым (непроходимый значит, что любой другой объект не может занять плитку непроходимого объекта).
- третье, прямоугольник в локальной переменной rect перемещенный на значения vx и vz, не должен пересекаться с прямоугольником, который храниться в rect проверяемого объекта.

Если все три условия выполняются мы возвращаем false, т.е. текущий объект не сможет пересечь проверяемый.
Для того, чтобы использовать этот метод, он должен вызываться перед добавлением значений скорости к значениям позиции объекта. Смотрим класс CollisionTest:

package
{
import com.friendsofed.isometric.DrawnIsoBox;
import com.friendsofed.isometric.DrawnIsoTile;
import com.friendsofed.isometric.IsoWorld;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.events.Event;
import flash.events.KeyboardEvent;
import flash.ui.Keyboard;

[SWF(backgroundColor=0xffffff)]
public class CollisionTest extends Sprite
{
private var world:IsoWorld;
private var box:DrawnIsoBox;
private var speed:Number = 4;

public function CollisionTest()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

world = new IsoWorld();
world.x = stage.stageWidth / 2;
world.y = 100;
addChild(world);

for(var i:int = 0; i < 20; i++)
{
for(var j:int = 0; j < 20; j++)
{
var tile:DrawnIsoTile = new DrawnIsoTile(20, 0xcccccc);
tile.position = new Point3D(i * 20, 0, j * 20);
world.addChildToFloor(tile);
}
}

box = new DrawnIsoBox(20, 0xff0000, 20);
box.x = 200;
box.z = 200;
world.addChildToWorld(box);

var newBox:DrawnIsoBox = new DrawnIsoBox(20, 0xcccccc, 20);
newBox.x = 300;
newBox.z = 300;
world.addChildToWorld(newBox);

stage.addEventListener(KeyboardEvent.KEY_DOWN, onKeyDown);
stage.addEventListener(KeyboardEvent.KEY_UP, onKeyUp);
}

private function onKeyDown(event:KeyboardEvent):void
{
switch(event.keyCode)
{
case Keyboard.UP :
box.vx = -speed;
break;

case Keyboard.DOWN :
box.vx = speed;
break;

case Keyboard.LEFT :
box.vz = speed;
break;

case Keyboard.RIGHT :
box.vz = -speed;
break;

default :
break;

}
addEventListener(Event.ENTER_FRAME, onEnterFrame);
}

private function onKeyUp(event:KeyboardEvent):void
{
box.vx = 0;
box.vz = 0;
removeEventListener(Event.ENTER_FRAME, onEnterFrame);
}

private function onEnterFrame(event:Event):void
{
if(world.canMove(box))
{
box.x += box.vx;
box.y += box.vy;
box.z += box.vz;
}
world.sort();
}
}
}


Как видите, единственное изменение здесь произошло в метод onEnterFrame, где мы добавили проверку значения, возвращаемого методом canMove перед добавлением значения скорости к позиции.


Использование внешней графики



Классы DrawnIsoTile и DrawnIsoBox хорошо подходят для тестирования и разработки, но непригодны в реальной игре. Вероятно, вы захотите сделать несколько детализированных изометрических объектов в таких программах как PhotoShop, Fireworks, или даже Flash. Вам нужен способ, чтобы поместить эту графику внутрь IsoObject. Для этого мы сделаем класс GraphicTile:

package com.friendsofed.isometric
{
import flash.display.DisplayObject;

public class GraphicTile extends IsoObject
{
public function GraphicTile(size:Number, classRef:Class, xoffset:Number, yoffset:Number):void
{
super(size);

var gfx:DisplayObject = new classRef() as DisplayObject;
gfx.x = -xoffset;
gfx.y = -yoffset;
addChild(gfx);
}
}
}


Как видите, этот класс расширяет IsoObject и принимает в качестве параметров размер size, ссылку на класс, смещение по х и у. Ссылка на класс это тот класс, который привязан к графическому объекту. Обычно графика присоединяется с помощью тега embed. Чтобы увидеть как все работает и зачем нужны смещения давайте посмотрим на какую-нибудь графику. Я не великий художник, но я нарисовал одну плитку размером 40х20 (сохранил ее в файл tile_01.png в той же папке, где лежит класс, использующий этот рисунок) и один блок размером 40х40 похожий на домик (его сохранил в файл tile_02.png). См рисунки 14 и 15.

tile_01.png
Рисунок 14. - tile_01.png



tile_02.png
Рисунок 15. – tile_02.png



Для примера и использовал класс WorldTest, изменив тип плитки на GraphicTile. Я добавляю два наших рисунка и использую их классы в конструкторе GraphicTile для создания плиток:

package
{
import com.friendsofed.isometric.GraphicTile;
import com.friendsofed.isometric.IsoUtils;
import com.friendsofed.isometric.IsoWorld;
import com.friendsofed.isometric.Point3D;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.events.MouseEvent;
import flash.geom.Point;

[SWF(backgroundColor=0xffffff)]
public class GraphicTest extends Sprite
{
private var world:IsoWorld;

[Embed(source="tile_01.png")]
private var Tile01:Class;

[Embed(source="tile_02.png")]
private var Tile02:Class;

public function GraphicTest()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

world = new IsoWorld();
world.x = stage.stageWidth / 2;
world.y = 100;
addChild(world);

for(var i:int = 0; i < 20; i++)
{
for(var j:int = 0; j < 20; j++)
{
var tile:GraphicTile = new GraphicTile(20, Tile01, 20, 10);
tile.position = new Point3D(i * 20, 0, j * 20);
world.addChildToFloor(tile);
}
}
stage.addEventListener(MouseEvent.CLICK, onWorldClick);
}

private function onWorldClick(event:MouseEvent):void
{
var box:GraphicTile = new GraphicTile(20, Tile02, 20, 30);
var pos:Point3D = IsoUtils.screenToIso(new Point(world.mouseX, world.mouseY));
pos.x = Math.round(pos.x / 20) * 20;
pos.y = Math.round(pos.y / 20) * 20;
pos.z = Math.round(pos.z / 20) * 20;
box.position = pos;
world.addChildToWorld(box);
}
}
}


Так что же за смещения, о которых мы говорили? Пусть при создании графики точка регистрации плитки находится в ее центре. Другими словами, когда мы добавляем плитку в позицию с координатами x, y центр плитки совпадает с этими координатами. Если плитка имеет высоту, то она простирается вверх от этой точки.
Но когда графика вложена и добавляется в список отображения ее точка регистрации находится в левом верхнем углу, для того, чтобы она находилась в центре графического объекта мы подвинем сам объект под эту точку. Для первой плитки это несложно, т.к. она плоская. Смещение по х = 20, по у = 10. Для второй надо учесть ее высоту: смещение по x = 20, смещение по у = 30.

Вот, что мы увидим, если запустим наш пример:

GraphicTile в действии
Рисунок 16. – GraphicTile в действии



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


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



Итак, у нас есть маленький изометрический движок. Мы можем размещать плитки, перемещать их по всем законам физики, сортировать по глубине, импортировать графику и т.д. У нас есть все, чтобы сделать небольшую игру. Но еще одна вещь была бы для нас очень полезна – некоторый способ, который позволял бы строить наши миры без необходимости прописывать в коде положения всех плиток и изменять эти миры без перекомпиляции всего проекта.
Это может быть сделано с помощью карт плиток, которые представляют собой простые текстовые файлы, в которых с помощью цифр и их порядка описано положение той или иной плитки. Простой пример карты:

0000000000
0111111110
0100000010
0100000010
0100000010
0100000010
0100000010
0100000010
0111111110
0000000000


Здесь сетка размером 10х10 плиток, преимущественно типа 0, с квадратом из плиток типа 1. Что за тип 0 и тип 1? Вам решать. Вы редактируете этот текстовый файл и сохраняете его содержимое там, где до него могут добраться игра или приложение. Приложение загружает этот файл, парсит и с помощью цикла создает плитку типа 0 или типа 1 в том порядке, в котором они прописаны в файле. Затем эта плитка добавляется в мир. Допустим типу 0 соответствует проходимый DrawnIsoTile, размером 20, с цветом 0xCCCCCC. Чтобы сделать процесс предельно простым я создал класс MapLoader, который грузит карты из указанного файла и позволяет вам регистрировать различные типы плиток. Когда карта будет загружена и отпарсена вы сможете из этого сделать свой мир. Нет ничего легче. Итак, класс MapLoader:

package com.friendsofed.isometric
{
import flash.events.Event;
import flash.events.EventDispatcher;
import flash.net.URLLoader;
import flash.net.URLRequest;
import flash.utils.getDefinitionByName;

public class MapLoader extends EventDispatcher
{
private var _grid:Array;
private var _loader:URLLoader;
private var _tileTypes:Object;


public function MapLoader()
{
_tileTypes = new Object();
}

/**
* Loads a text file from the specified url.
* @param url The location of the text file to load.
*/
public function loadMap(url:String):void
{
_loader = new URLLoader();
_loader.addEventListener(Event.COMPLETE, onLoad);
_loader.load(new URLRequest(url));
}

/**
* Parses text file into tile definitions and map.
*/
private function onLoad(event:Event):void
{
_grid = new Array();
var data:String = _loader.data;

// first get each line of the file.
var lines:Array = data.split("\n");
for(var i:int = 0; i < lines.length; i++)
{
var line:String = lines[i];

// if line is a tile type definition.
if(isDefinition(line))
{
parseDefinition(line);
}
// otherwise, if line is not empty and not a comment, it's a list of tile types. add them to grid.
else if(!lineIsEmpty(line) && !isComment(line))
{
var cells:Array = line.split(" ");
_grid.push(cells);
}
}
dispatchEvent(new Event(Event.COMPLETE));
}

private function parseDefinition(line:String):void
{
// break apart the line into tokens
var tokens:Array = line.split(" ");

// get rid of #
tokens.shift();

// first token is the symbol
var symbol:String = tokens.shift() as String;

// loop through the rest of the tokens, which are key/value pairs separated by :
var definition:Object = new Object();
for(var i:int = 0; i < tokens.length; i++)
{
var key:String = tokens[i].split(":")[0];
var val:String = tokens[i].split(":")[1];
definition[key] = val;
}

// register the type and definition
setTileType(symbol, definition);
}

/**
* Links a symbol with a definition object.
* @param symbol The character to use for the definition.
* @param definition A generic object with definition properties
*/
public function setTileType(symbol:String, definition:Object):void
{
_tileTypes[symbol] = definition;
}

/**
* Creates an IsoWorld, iterates through loaded map, adding tiles to it based on map and definitions.
* @size The tile size to use when making the world.
* @return A fully populated IsoWorld instance.
*/
public function makeWorld(size:Number):IsoWorld
{
var world:IsoWorld = new IsoWorld();
for(var i:int = 0; i < _grid.length; i++)
{
for(var j:int = 0; j < _grid[i].length; j++)
{
var cellType:String = _grid[i][j];
var cell:Object = _tileTypes[cellType];
var tile:IsoObject;
switch(cell.type)
{
case "DrawnIsoTile" :
tile = new DrawnIsoTile(size, parseInt(cell.color), parseInt(cell.height));
break;

case "DrawnIsoBox" :
tile = new DrawnIsoBox(size, parseInt(cell.color), parseInt(cell.height));
break;

case "GraphicTile" :
var graphicClass:Class = getDefinitionByName(cell.graphicClass) as Class;
tile = new GraphicTile(size, graphicClass, parseInt(cell.xoffset), parseInt(cell.yoffset));
break;

default :
tile = new IsoObject(size);
break;
}
tile.walkable = cell.walkable == "true";
tile.x = j * size;
tile.z = i * size;
world.addChild(tile);
}
}
return world;
}

/**
* Returns true if line contains only spaces, false if any other characters.
* @param line The string to test.
*/
private function lineIsEmpty(line:String):Boolean
{
for(var i:int = 0; i < line.length; i++)
{
if(line.charAt(i) != " ") return false;
}
return true;
}

/**
* Returns true if line is a comment (starts with //).
* @param line The string to test.
*/
private function isComment(line:String):Boolean
{
return line.indexOf("//") == 0;
}

/**
* Returns true if line is a definition (starts with #).
* @param line The string to test.
*/
private function isDefinition(line:String):Boolean
{
return line.indexOf("#") == 0;
}
}
}


Это довольно сложный класс, но он делает построение мера очень простым. Для начала мы загружаем текстовый файл, указанный в качестве аргумента метода loadMap(). Вот пример простого текстового файла с картой:

//это комментарий
# 0 type:GraphicTile graphicClass:MapTest_Tile01 xoffset:20 yoffset:10 walkable:true
# 1 type:GraphicTile graphicClass:MapTest_Tile02 xoffset:20 yoffset:30 walkable:false
# 2 type:DrawnIsoBox color:0xff6666 walkable:false height:20
# 3 type:DrawnIsoTile color:0x6666ff walkable:false
0 0 0 0 0 0 0 0 0 0
0 1 1 1 1 1 1 1 1 0
0 1 0 0 0 0 0 0 0 0
0 1 0 3 3 3 3 0 0 0
0 1 0 3 2 2 3 0 0 0
0 1 0 3 2 2 3 0 0 0
0 1 0 3 3 3 3 0 0 0
0 1 0 0 0 0 0 0 0 0
0 1 1 1 1 1 1 1 1 0
0 0 0 0 0 0 0 0 0 0


Строки, которые начинаются с // это комментарии и будут проигнорированы. Строчки начинающиеся с # - это определения типов плиток. Определение плитки представляет собой символ (0, 1, 2, 3 в нашем случае) и списка пар свойство/значение.
Когда файл карты загружен срабатывает метод onLoad, который парсит текст и делает следующее:
1. Разбирает файл на массив строк.
2. Для каждой строки определяется, является ли она определением плитки, пустой строкой или рядом плиток.
3. Если строка содержит определение, она парсится методом parseDefinition(). Этот метод берет каждую пару свойство/значение и присваивает его свойству объекта, который затем заносится в _tileTypes.
4. Строки, содержащие ряд плиток разбираются на массив символов и записываются в массив _grid.
5. Когда все сделано, отсылается событие, с помощью которого вы узнаете, что файл загружен и отпарсен. Теперь вы можете вызвать метод makeWorld(), который добавляет каждый элемент массива _grid в экземпляр класса IsoWorld, но перед этим он смотрит на тип каждого элемента.
Использовать этот класс очень просто. Вот пример:

package
{
import com.friendsofed.isometric.DrawnIsoBox;
import com.friendsofed.isometric.GraphicTile;
import com.friendsofed.isometric.IsoWorld;
import com.friendsofed.isometric.MapLoader;

import flash.display.Sprite;
import flash.display.StageAlign;
import flash.display.StageScaleMode;
import flash.events.Event;

[SWF(backgroundColor=0xffffff)]
public class MapTest extends Sprite
{
private var _world:IsoWorld;
private var _floor:IsoWorld;
private var mapLoader:MapLoader;

[Embed(source="tile_01.png")]
private var Tile01:Class;

[Embed(source="tile_02.png")]
private var Tile02:Class;

public function MapTest()
{
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;

mapLoader = new MapLoader();
mapLoader.addEventListener(Event.COMPLETE, onMapComplete);
mapLoader.loadMap("map.txt");
}

private function onMapComplete(event:Event):void
{
_world = mapLoader.makeWorld(20);
_world.x = stage.stageWidth / 2;
_world.y = 100;
addChild(_world);
}
}
}


Вы создаете MapLoader, слушаете событие Event.COMPLETE и загружаете карту. Когда все готово вы вызываете метод makeWorld и добавляете свой мир в список отображение. Вот что должно было получится:

Класс MapLoader в действии
Рисунок 17. – Класс MapLoader в действии



На этом у меня все.

Исходники

2 комментария:

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

    ОтветитьУдалить
  2. очень познавательно. Только как быть с z-сортировкой объектов, занимающих несколько тайлов? Хотя бы для прямоугольных областей. Сравнивать только одну из вершин этих областей? Это некорректно, с ходу можно привести несколько примеров (в основном при наличии вытянутых областей). Последовательно сравнивать глубину каждого из тайлов первого объекта с глубиной всех тайлов второго? Это непроизводительно.

    ОтветитьУдалить