Геометрия в игре

Предисловие

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

Координаты

Когда речь заходит о позиционировании, первым делом необходимо определить точку отсчёта. Игра использует прямоугольную систему координат, которую можно легко понять даже если вы плохо учились в школе.

Перемещайте точки зажав ЛКМ

Зелёные линии это оси. Горизонтальная именуется X или ось абсцисс, вертикальная соответственно Y или ось ординат. В их пересечении располагается точка ноль.

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

Расстояние

Когда речь заходит о расстоянии, то первым на ум приходит расстояние между двумя точками. Но мы не будем сходу окунаться в двумерное пространство, а ограничимся одним измерением, то бишь точками на координатной прямой.

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

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

Перемещайте точки зажав ЛКМ

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

Если вы не понимаете, при чём здесь треугольники, то эта интерактивная демонстрация вам всё пояснит.

Перемещайте точки зажав ЛКМ

Как видите, у нас всегда получается треугольник ABC, где AB является гипотенузой. Её длину несложно вычислить по теореме Пифагора: AB2 = AC2 + BC2.




Вычисление в игре

В файле blizzard.j находится специально обученная функция:

function DistanceBetweenPoints takes location locA, location locB returns real
    local real dx = GetLocationX(locB) - GetLocationX(locA)
    local real dy = GetLocationY(locB) - GetLocationY(locA)
    return SquareRoot(dx * dx + dy * dy)
endfunction

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

native MathDistanceBetweenPoints takes real fromX, real fromY, real toX, real toY returns real
native MathDistanceBetweenLocations takes location fromLoc, location toLoc returns real

Углы

Когда речь заходит про измерение углов, те, кто учился в школе сразу вспоминают транспортир и разметку от нуля до 360. Это всё прекрасно работает в своей области применения, но когда дело доходит до программирования то все культурные люди переходят на радианы. Они имеют много интересных и полезных геометрических свойств из-за чего в большинстве языков программирования геометрические функции принимают на вход именно радианы.

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

Перемещайте точку зажав ЛКМ

Чтобы не запутаться в единицах измерения будет очень полезно запомнить их обозначения:

Получение в игре

В ваниле получить угол можно только у юнита:

constant native GetUnitFacing takes unit whichUnit returns real

В UjAPI, естественно, функций будет немного больше:

constant native GetUnitFacing takes unit whichUnit returns real
native GetDoodadFacing takes doodad whichDoodad returns real
native GetSpecialEffectFacing takes effect whichEffect returns real
native GetTrackableFacing takes trackable whichTrackable returns real
native GetWidgetFacing takes widget whichWidget returns real
native GetDestructableFacing takes destructable whichDestructable returns real
native GetItemFacing takes item whichItem returns real
native GetProjectileFacing takes projectile whichProjectile returns real
native GetFrameSpriteFacing takes framehandle whichFrame returns real

Все эти функции возвращают угол в градусах в диапазоне 0 ... 360. Чтобы перевести их в радианы, можете просто умножить их на константу bj_DEGTORAD или воспользоваться следующей нативкой из UjAPI:

native Deg2Rad takes real degrees returns real

Угол между двумя точками

Как вы наверное догадались, у точек нет такого свойства как угол. Под этим обычно понимают минимальный угол между векторами. Если быть точнее, то минимальный угол, на который нужно повернуть вектор, чтоб он был сонаправлен с осью X. Звучит конечно слишком умно, поэтому смотрите на интерактивную демонстрацию и читайте объяснение ниже.

Перемещайте точки зажав ЛКМ

Попытаемся вычислить угол AB, то бишь найти минимальный угол, на который необходимо повернуть точку B вокруг точки A так, чтобы AB совпал с AB2.

Сместим AB таким образом, чтоб A совпала с началом координат. Это несложно. Просто разместим там точку A1.

Разместим точку B1 относительно A1 таким же образом, как и B находится относительно A. Если вы внимательно посмотрите на интерактивный пример то быстро догадаетесь как это сделать:



Ну и в завершении, нам нужно вычислить арктангенс с помощью функции Atan2.

Арктангенс это угол между осью X и линией, проведенной из начала координат 0, 0 в точку с координатами x, y. Угол определяется в радианах в диапазоне от до π, исключая .

Так, как мы уже переместили A в 0, 0, то нам остаётся только передать координаты B1 в функцию:


Вычисление в игре

В файле blizzard.j есть специально обученная функция.

function AngleBetweenPoints takes location locA, location locB returns real
    return bj_RADTODEG * Atan2(GetLocationY(locB) - GetLocationY(locA), GetLocationX(locB) - GetLocationX(locA))
endfunction

Для счастливых обладателей UjAPI были добавлены следующие нативки.

native MathAngleBetweenPoints takes real fromX, real fromY, real toX, real toY returns real
native MathAngleBetweenLocations takes location fromLoc, location toLoc returns real

Все вышеперечисленные функции возвращают угол в градусах в диапазоне -180 ... 180, с чем не очень удобно работать. Поэтому напишем свою функцию:

function AngleBetweenXY takes real xa, real ya, real xb, real yb returns real
    return Atan2(by - ay, bx - ax)
endfunction

Полярное смещение

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

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

Для лучшего понимания интерактивная демонстрация к вашим услугам.

Перемещайте точки зажав ЛКМ

Чтоб лишний раз не повторяться допустим, что расстояние (len) и угол (rad) AB мы уже вычислили и наша задача сместить точку B в точку B2 на то же расстояние под тем же углом.

Здесь нам опять понадобится смещение A в 0, 0. Можно воспользоваться способом, который мы применяли при вычислении углов:

B1x = Bx - Ax
B1y = By - Ay

Хоть этот способ и прост, но он не позволяет нам изменять угол. Вот здесь на помощь приходит полярная система координат. Чтоб найти точку, лежащую на расстоянии (len) под углом (rad) от точки 0, 0 нужно воспользоваться формулой:

B1x = Cos(rad) * len
B1y = Sin(rad) * len

Чтоб нить разговора не потерялась, напомню, что мы смещаем точку B в точку B2 на расстояние len под углом rad. На данный момент мы получили точку B1, которая находится на расстоянии len под углом rad от 0, 0. И если прибавить к точке B1 координаты B, то мы получим точку B2, которая находится на расстоянии len под углом rad от B.

Запишем итоговую формулу:

B2x = Cos(rad) * len + Bx
B2y = Sin(rad) * len + By

Вычисление в игре

В файле blizzard.j вы можете найти функцию:

function PolarProjectionBJ takes location source, real dist, real angle returns location
    local real x = GetLocationX(source) + dist * Cos(angle * bj_DEGTORAD)
    local real y = GetLocationY(source) + dist * Sin(angle * bj_DEGTORAD)
    return Location(x, y)
endfunction

Она принимает угол в градусах, и создаёт Location, который нужно потом уничтожать во избежание утечек.

В UjAPI тоже есть функции:

native MathPointProjectionX takes real x, real angle, real distance returns real
native MathPointProjectionY takes real y, real angle, real distance returns real

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

function PolarProjectionX takes real x, real dist, real angle returns real
    return Cos(angle) * dist + x
endfunction
function PolarProjectionY takes real y, real dist, real angle returns real
    return Sin(angle) * dist + y
endfunction

Нормализация угла

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

Перемещайте точки зажав ЛКМ

Рассмотрим бытовую ситуацию - вам нужно нарисовать пентаграмму с центром в точке A и одним из лучей в точке B. Простейшим решением будет найти угол между лучами, в нашем случае это 2π/5, и с помощью полярного смещения нарисовать остальные лучи.

Так как мы уже умеем вычислять расстояние, то осталось только посчитать углы:






Как видите, у некоторых углов значение превышает 2π, то есть полный угол. Для расположений точки на плоскости такой угол вполне годится. Но, когда дело коснётся оборотов, то можно ступить ногами в жир.

Нормализуем все углы и обрамим нашу пентаграмму пятиугольником. Для краткости, обозначим функцию нормализации как N:






Вычисление в игре

Для нормализации угла функций никто не завёз, поэтому мы напишем её руками:

function AngleNormalize takes real angle returns real
    if angle > -bj_PI and angle <= bj_PI then
        return angle
    endif
    set angle = ModuloReal(angle + bj_PI, bj_PI * 2)
    if angle < 0 then
        return angle + bj_PI
    else
        return angle - bj_PI
    endif
endfunction

Угол поворота

Угол поворота — это минимальный угол, на который нужно повернуть луч, исходящий из точки O, чтобы он совпал с другим лучом, исходящим из этой же точки.

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

Перемещайте точки зажав ЛКМ

Из точки A исходят два луча: AB и AC. Углом поворота D для угла AB по отношению к AC будет такое значение, при котором AB + D = AC.

Можно наивно вычислить D из уравнения AB + D = AC:


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

Для проверки разместим точку E таким образом, что угол AE = AB + D:




Как видите, на интерактивной демонстрации угол AE всегда совпадает с углом AC, но их численные значения не всегда одинаковы.



Напрашивается вопрос, а зачем же это всё нужно? Всё просто:

Вычисление в игре

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

function AngleOfRotation takes real a, real b returns real
    local real d = b - a
    if d > bj_PI then
        return d - 2 * bj_PI
    elseif d < -bj_PI then
        return d + 2 * bj_PI
    endif
    return d
endfunction

Угловой коэффициент прямой

Он же наклон прямой численно равен тангенсу угла между положительным направлением оси абсцисс и данной прямой.

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

Перемещайте точки зажав ЛКМ

Вычисляется наклон довольно просто:

k = Δy / Δx

Если вы не знакомы с дельтой, то вкратце, это разница между чем либо. В нашем случае между координатами:


Если вы беспокоитесь о порядке точек, то легко можно убедиться, что он не важен:


Учтите, что параллельная оси Y прямая имеет наклон равный бесконечности. В языках которые её не поддерживают это может грозить многими печалями.

На интерактивной демонстрации выше вы можете наблюдать две прямые: AB и AC и их наклон обозначенный соответственно kAB и kCD. У него есть довольно интересные свойства.

Прямые перпендикулярны при:

kAB * kCD = -1

Прямые параллельны при:

kAB - kCD = 0

Вычисление в игре

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

Перпендикуляр из точки к прямой

Для всевозможных кастов по прямой зачастую используют всевозможные алгоритмы разной степени унылости. Пора прекратить это безобразие.

Перемещайте точки зажав ЛКМ

В данном примере у нас есть прямая, заданная двумя точками A, B и точка С. Функция, приведённая ниже вычислит точку С1, которая является кратчайшим расстоянием от прямой AB до точки C.

Вычисление в игре

Как вы уже наверно догадались, разработчикам абсолютно наплевать на геометрию и функции придётся писать руками:

globals
   real LinePointPerpendicularX = 0
   real LinePointPerpendicularY = 0
endglobals

function LinePointPerpendicular takes real xa, real ya, real xb, real yb, real px, real py returns nothing
    local real m
    local real c
    local real mPerpendicular
    local real cPerpendicular

    // Прямая, параллельная оси X
    if ya == yb then
        set LinePointPerpendicularX = px
        set LinePointPerpendicularY = ya
        return
    endif

    // Прямая, параллельная оси Y
    if xa == xb then
        set LinePointPerpendicularX = xa
        set LinePointPerpendicularY = py
        return
    endif

    set m = (yb - ya) / (xb - xa) // Находим угловой коэффициент прямой AB
    set c = ya - m * xa // Находим свободный член c уравнения прямой AB
    set mPerpendicular = -1 / m // Находим угловой коэффициент перпендикулярной прямой
    set cPerpendicular = py - mPerpendicular * px // Находим свободный член c уравнения перпендикулярной прямой, проходящей через точку (x, y)

    // Находим точку пересечения перпендикулярной прямой с прямой AB
    set LinePointPerpendicularX = (cPerpendicular - c) / (m - mPerpendicular)
    set LinePointPerpendicularY = m * LinePointPerpendicularX + c
endfunction

Данная функция принимает координаты двух точек, обозначающих прямую xa, ya, xb, yb, координаты точки px, py и устанавливает в глобальных переменных координаты искомой точки.

Далее вы можете воспользоваться нативкой, которая учитывает коллизию юнита:

constant native IsUnitInRangeXY takes unit whichUnit, real x, real y, real distance returns boolean

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

Пересечение отрезков

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

Перемещайте точки зажав ЛКМ

Вычисление в игре

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

globals
    real LineSegmentIntersectX = 0
    real LineSegmentIntersectY = 0
    boolean LineSegmentIntersectA = false
    boolean LineSegmentIntersectB = false
endglobals

function LineSegmentIntersect takes real ax, real ay, real bx, real by, real cx, real cy, real dx, real dy returns boolean
    local real a
    local real b
    local real na
    local real nb
    local real d = (dy - cy) * (bx - ax) - (dx - cx) * (by - ay)

    set LineSegmentIntersectX = 0
    set LineSegmentIntersectY = 0
    set LineSegmentIntersectA = false
    set LineSegmentIntersectB = false

    if d == 0 then
        return false
    endif

    set a = ay - cy
    set b = ax - cx
    set na = (dx - cx) * a - (dy - cy) * b
    set nb = (bx - ax) * a - (by - ay) * b
    set a = na / d
    set b = nb / d

    set LineSegmentIntersectX = ax + a * (bx - ax)
    set LineSegmentIntersectY = ay + a * (by - ay)
    set LineSegmentIntersectA = a > 0 and a < 1 // Находится ли точка пересечения на первом отрезке
    set LineSegmentIntersectB = b > 0 and b < 1 // Находится ли точка пересечения на втором отрезке

    return true
endfunction

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

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

Ax Ay Bx By Cx Cy Dx Dy

В таком случае LineSegmentIntersectA будет указывать, лежит ли точка пересечения на отрезке AB и соответственно LineSegmentIntersectB на CD.

Принадлежность точки многоугольнику

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

Перемещайте точки зажав ЛКМ

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

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

Вычисление в игре

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

globals
    hashtable PolygonHT = InitHashtable()
endglobals

function PolygonDestroy takes integer index returns nothing
    call FlushChildHashtable(PolygonHT, index)
endfunction

function PolygonAddPoint takes integer index, real x, real y returns nothing
    local integer cursor
    local integer idx
    if HaveSavedInteger(PolygonHT, index, 0) then
        set cursor = LoadInteger(PolygonHT, index, 0)
    else
        set cursor = -1
    endif
    set cursor = cursor + 1
    set idx = cursor * 2

    call SaveReal(PolygonHT, index, idx, x)
    call SaveReal(PolygonHT, index, idx + 1, y)

    call SaveInteger(PolygonHT, index, 0, cursor)
endfunction

function PolygonCountPoint takes integer index returns integer
    if HaveSavedInteger(PolygonHT, index, 0) then
        return LoadInteger(PolygonHT, index, 0) + 1
    else
        return 0
    endif
endfunction

function PolygonEdgeContainPoint takes real ax, real ay, real bx, real by, real px, real py returns boolean
    if (py - ay) * (bx - ax) - (px - ax) * (by - ay) != 0 then
        return false
    endif
    return RMinBJ(ax, bx) <= px and px <= RMaxBJ(ax, bx) and RMinBJ(ay, by) <= py and py <= RMaxBJ(ay, by)
endfunction

function PolygonContainPoint takes integer index, real x, real y returns boolean
    local integer count = PolygonCountPoint(index)
    local boolean inside = false
    local integer i = 0
    local integer j = count - 1
    local real ax
    local real ay
    local real bx
    local real by

    if count < 3 then
        return false
    endif

    loop
        exitwhen i >= count

        set ax = LoadReal(PolygonHT, index, i * 2)
        set ay = LoadReal(PolygonHT, index, i * 2 + 1)
        set bx = LoadReal(PolygonHT, index, j * 2)
        set by = LoadReal(PolygonHT, index, j * 2 + 1)

        // Дополнительная проверка на пересечение с ребром.
        // Можно безболезненно удалить для повышения производительности.
        if PolygonEdgeContainPoint(ax, ay, bx, by, x, y) then
            return true
        endif

        if (ay > y) != (by > y) and x < ((bx - ax) * (y - ay)) / (by - ay) + ax then
            set inside = not inside
        endif
        set j = i
        set i = i + 1
    endloop

    return inside
endfunction

Построение выпуклой оболочки

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

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

Перемещайте точки зажав ЛКМ

Для построения воспользуемся алгоритмом Quickhull, или, говоря по-русски, алгоритмом быстрой оболочки.

Подробнее можете почитать на официальном сайте алгоритма, нам важна только его сложность: O(N log N).

Вычисление в игре

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

globals
    real array ConvexHullInputX
    real array ConvexHullInputY
    integer array ConvexHullInputIndex
    integer ConvexHullInputCursor = -1
    real array ConvexHullX
    real array ConvexHullY
    integer ConvexHullCursor = -1
endglobals

function ConvexHullInputAdd takes real x, real y returns nothing
    set ConvexHullInputCursor = ConvexHullInputCursor + 1
    set ConvexHullInputIndex[ConvexHullInputCursor] = ConvexHullInputCursor
    set ConvexHullInputX[ConvexHullInputCursor] = x
    set ConvexHullInputY[ConvexHullInputCursor] = y
endfunction

function ConvexHullInputSortSwap takes integer i, integer j returns nothing
    local integer temp = ConvexHullInputIndex[i]
    set ConvexHullInputIndex[i] = ConvexHullInputIndex[j]
    set ConvexHullInputIndex[j] = temp
endfunction

function ConvexHullInputSort takes integer low, integer high returns nothing
    local integer i = low - 1
    local integer j = low
    local integer ji
    local real jx
    local real jy
    local integer pi
    local real px
    local real py

    if low >= high then
        return
    endif

    loop
        exitwhen j >= high
        set ji = ConvexHullInputIndex[j]
        set jx = ConvexHullInputX[ji]
        set jy = ConvexHullInputY[ji]
        set pi = ConvexHullInputIndex[high]
        set px = ConvexHullInputX[pi]
        set py = ConvexHullInputY[pi]
        if jx < px or jx == px and jy < py then
            set i = i + 1
            call ConvexHullInputSortSwap(i, j)
        endif
        set j = j + 1
    endloop
    call ConvexHullInputSortSwap(i + 1, high)

    call ConvexHullInputSort(low, i)
    call ConvexHullInputSort(i + 2, high)
endfunction

function ConvexHullRemoveMiddle takes real cx, real cy returns boolean
    local real ax = ConvexHullX[ConvexHullCursor - 1]
    local real ay = ConvexHullY[ConvexHullCursor - 1]
    local real bx = ConvexHullX[ConvexHullCursor]
    local real by = ConvexHullY[ConvexHullCursor]
    local real abx = ax - bx
    local real aby = ay - by
    local real cbx = cx - bx
    local real cby = cy - by
    local real cross = abx * cby - aby * cbx
    return cross < 0 or cross == 0 and abx * cbx + aby * cby <= 0
endfunction

function ConvexHull takes nothing returns nothing
    local real n = ConvexHullInputCursor + 1
    local integer i = 0
    local integer j
    local integer ji
    local real jx
    local real jy

    call ConvexHullInputSort(0, ConvexHullInputCursor)
    set ConvexHullCursor = -1

    loop
        exitwhen i >= 2 * n

        if i < n then
            set j = i
        else
            set j = 2 * n - 1 - i
        endif

        set ji = ConvexHullInputIndex[j]
        set jx = ConvexHullInputX[ji]
        set jy = ConvexHullInputY[ji]
        loop
            exitwhen ConvexHullCursor < 1 or  not ConvexHullRemoveMiddle(jx, jy)
            set ConvexHullCursor = ConvexHullCursor - 1
        endloop

        set ConvexHullCursor = ConvexHullCursor + 1
        set ConvexHullX[ConvexHullCursor] = jx
        set ConvexHullY[ConvexHullCursor] = jy
        set i = i + 1
    endloop

    set ConvexHullCursor = ConvexHullCursor - 1
endfunction

Парабола