George Judkin дата публикации 28-03-2005 08:59 Алгоритм поиска пути на карте
Когда перед человеком, не склонным к разработке
собственных алгоритмов, встает задача поиска пути на карте, он пытается
вспомнить все, чему его когда-то учили. Вспоминается с трудом, так как
знания за ненадобностью имеют тенденцию улетучиваться. Но потом все же
всплывает волшебное словосочетание “Алгоритм Дейкстры”. Этого
достаточно, потому что остальную часть работы по “вспоминанию” берет на
себя интернет. А если интернет кроме описания алгоритма, выдаст еще
что-то типа “что алгоритм Дейкстра -- один из лучших”, то будущий автор
приходит в восторг и больше ни о чем не думает.
Но вот ведь какая бяка получается: алгоритм Дейкстры
действительно достаточно эффективен. Но он разработан для поиска пути в
произвольном графе, когда о структуре графа ничего заранее неизвестно.
Однако для частных случаев, как правило, можно разработать алгоритмы,
которые будут более эффективны, чем общие. Например, для деления целых
чисел очень эффективным является алгоритм деления столбиком. Но если
делитель – степень двойки, а делимое представлено в двоичном виде, то
значительно проще получить результат поразрядным сдвигом.
А для поиска пути на квадратной сетке наиболее
эффективным является так называемый “волновой алгоритм”. Кто его первым
придумал, сказать сейчас, наверное, уже невозможно. Но точно можно
сказать, что различные варианты волнового алгоритма придумывал каждый
нормальный программист, перед которым вставала задача поиска пути на
карте из квадратиков (я его первый раз придумал в 1986 году для поиска
пути в лабиринте). И название у всех возникает примерно одно и то же:
уж больно его работа похожа на бегущую круговую волну.
У читателя может возникнуть вопрос, а зачем я все это
пишу, когда это давно уже всем известно. Выясняется, что не всем. В
Королевстве уже имеются две статьи по этому поводу. Но вопросы
продолжают задавать. Попытки отослать к статьям или передать кусок
реально работающей программы результата не дали. Поэтому я и решил
написать простую и понятную реализацию алгоритма и описать его принцип
работы.
Сразу маленькое замечание к профессионалам: программу я
не оптимизировал. Это сделано не потому, что я не умею. Просто пишется
это для начинающих. А разобраться в оптимизированной программе порой
затруднительно даже профессионалу. Еще хочу добавить, что я -- далеко
не господь бог: так как спешил написать за выходные, то вполне мог
что-то накосячить.
Итак, у нас есть в общем случае прямоугольная карта с
квадратной сеткой. Каждая клетка имеет тип территории. Стоимость въезда
на клетку представляется целым числом и зависит от типа территории и
направления, по которому въехали (я использую слово “въехать”, а не,
допустим, “войти” или “продвинуться”, потому что последний писал этот
алгоритм для игры, в которой двигалась боевая техника; мне так
удобнее). Некоторые клетки имеют тип “препятствие”. Движение по этим
клеткам невозможно. Имеется восемь допустимых направлений движения,
которые обозначаются целыми числами от 0 до 7 (0 – вертикально вверх,
дальнейшее приращение на единицу означает поворот направления движения
на 45 градусов по часовой стрелке). На карте заданы начальная и
конечная точки. Нужно найти минимальный путь из начальной точки в
конечную (точнее один из минимальных путей, так как может быть много
различных путей с одинаковыми затратами единиц движения).
Для передачи принципа работы алгоритма буду
рассматривать задачу как поиск оптимального движения автомобиля из
начальной точки в конечную.
Из начальной точки выезжают автомобили по всем
возможным направлениям (не все из восьми направлений могут быть
возможны, так как могут встретиться препятствия и край карты). Чтобы
доехать до соседней клетки требуется время, определяемое (как уже
говорилось) типом клетки и направлением движения. Соответственно, не
все автомобили, выехавшие из начальной точки в один и тот же момент
времени достигнут соседних клеток одновременно. После того, как
автомобиль до клетки все же доехал, то производится проверка: первым ли
он доехал до этой клетки. Если здесь уже проезжали, то автомобиль
снимается и в дальнейшем движении не участвует. Если он приехал в эту
клетку первым, то в ней фиксируется порядковый номер этой клетки в
маршруте движения автомобиля и направление, с которого он приехал.
Номер клетки в маршруте движения также является признаком того, что по
клетке проехали. Изначально номера всех клеток равны –1. Затем
производится проверка: является ли данная точка конечной. Если точка
конечная, то алгоритм работу заканчивает. В противном случае из этой
клетки отправляются автомобили по всем возможным направлениям. Из
возможных направлений кроме препятствий и края карты исключаются
клетки, по которым уже проехали. Все.
Все необходимое для работы алгоритма вынесено в отдельный модуль (PathFind.pas).
Этот модуль является отчуждаемым и может быть использован в других
проектах (если кому-то лень писать самостоятельно, могут взять готовое
решение и использовать у себя без переработки). Модуль обладает
некоторой гибкостью и может быть использован для решения достаточно
широкого круга задач аналогичного класса.
unit PathFind;
interface
uses
Windows, Classes;
type
TPath = array of TPoint;
TPathMapCell = record
Distance : Integer;
Direction : Integer;
end;
TPathMap = array of array of TPathMapCell;
TGetCostFunc = Function(X,Y,Direction : Integer) : Integer;
function MakePathMap(
MapWidth,MapHeight : Integer;
StartX,StartY : Integer;
GetCostFunc : TGetCostFunc) : TPathMap;
function FindPathOnMap(PathMap : TPathMap; X,Y : Integer) : TPath;
function FindPath(
MapWidth,MapHeight : Integer;
StartX,StartY,StopX,StopY : Integer;
GetCostFunc : TGetCostFunc) : TPath;
| |
Если путь не существует, то функции FindPath и FindPathOnMap возвращают NIL. MapWidth и MapHeight -- ширина и высота основной карты. Могут быть различными.
Существуют два возможных варианта поиска путей. Либо с помощью функции FindPath находим сразу путь из одной точки в другую. Либо строим карту всех путей из заданной точки (MakePathMap) и получаем путь в любую конечную точку из заданной начальной (FindPathOnMap). В демонстрационном примере используется именно второй вариант.
Переходим к реализации. Сразу скажу, что мне очень не понравился подход Вадима Кеглеса, описанный в его статье Определение кратчайшего пути между двумя точками:
такое количество циклов – это жуткая трата ресурсов. У себя я решил
хранить фронт волны. Для этого был специально создан класс TWave.
type
TWaveCell = record
X,Y : Integer;
Cost : Integer;
Direction : Integer;
end;
TWave = class
private
FData : array of TWaveCell;
FPos : Integer;
FCount : Integer;
FMinCost : Integer;
function GetItem : TWaveCell;
public
property Item : TWaveCell read GetItem;
property MinCost : Integer read FMinCost;
constructor Create;
destructor Destroy; override;
procedure Add(NewX,NewY,NewCost,NewDirection : Integer);
procedure Clear;
function Start : Boolean;
function Next : Boolean;
end;
| |
Реализацию методов данного класса описывать не буду.
Там все достаточно просто и понятно. Единственное, на что надо обратить
внимание, это на увеличение размера динамического массива: я его
увеличиваю кусками по 30 элементов (число взято от фонаря). Если кому
не нравится, то перепишите (метод TWave.Add).
Несколько слов о дополнительных функциях. Имеется пара
вспомогательных функций, для перевода направления в приращения
координат. Так же к дополнительным относится функция получения
стоимости движения GetCost.
var
GetCostFunc : TGetCostFunc;
function GetCost(X,Y,Direction : Integer) : Integer;
begin
Direction:=(Direction AND 7);
if (X < 0) OR (X >= MapWidth) OR (Y < 0) OR (Y >= MapHeight)
then
Result:=-1
else
Result:=GetCostFunc(X,Y,Direction);
end;
function DirToDX(Direction : Integer) : Integer;
begin
case Direction of
0,4 : Result:=0;
1..3 : Result:=1;
else
Result:=-1;
end;
end;
function DirToDY(Direction : Integer) : Integer;
begin
case Direction of
2,6 : Result:=0;
3..5 : Result:=1;
else
Result:=-1;
end;
end;
| |
Функция GetCost является предобработчиком
запроса на стоимость движения. Она вносит корректировки в значение
Direction, если оно выходит за пределы заданного диапазона 0..7
(остаток от предыдущей версии, когда операции с направлением могли
привести к выходу за границы диапазона). Она также проверяет
запрошенные координаты на предмет невыхода за границы карты. В случае
выхода возвращает код препятствия, дабы избежать продолжения движения в
том направлении.
Основная функция модуля – FillPathMap – строит карту пути от начальной до конечной точки.
function FillPathMap(X1,Y1,X2,Y2 : Integer) : TPathMap;
var
OldWave, NewWave : TWave;
Finished : Boolean;
I : TWaveCell;
procedure PreparePathMap;
var
X,Y : Integer;
begin
SetLength(Result,MapHeight,MapWidth);
for Y:=0 to (MapHeight-1) do
for X:=0 to (MapWidth-1) do
Result[Y,X].Distance:=-1;
end;
procedure TestNeighbours;
var
X,Y,C,D : Integer;
begin
for D:=0 to 7 do
begin
X:=OldWave.Item.X+DirToDX(D);
Y:=OldWave.Item.Y+DirToDY(D);
C:=GetCost(X,Y,D);
if (C >= 0) AND (Result[Y,X].Distance < 0)
then
NewWave.Add(X,Y,C,D);
end;
end;
procedure ExchangeWaves;
var
W : TWave;
begin
W:=OldWave;
OldWave:=NewWave;
NewWave:=W;
NewWave.Clear;
end;
begin
PreparePathMap;
OldWave:=TWave.Create;
NewWave:=TWave.Create;
Result[Y1,X1].Distance:=0;
OldWave.Add(X1,Y1,0,0);
TestNeighbours;
Finished:=((X1 = X2) AND (Y1 = Y2));
while NOT Finished do
begin
ExchangeWaves;
if NOT OldWave.Start then Break;
repeat
I:=OldWave.Item;
I.Cost:=I.Cost-OldWave.MinCost;
if I.Cost > 0
then
NewWave.Add(I.X,I.Y,I.Cost,I.Direction)
else
begin
if Result[I.Y,I.X].Distance >= 0 then Continue;
Result[I.Y,I.X].Distance:=Result[I.Y-DirToDY(I.Direction),
I.X-DirToDX(I.Direction)].Distance+1;
Result[I.Y,I.X].Direction:=I.Direction;
Finished:=((I.X = X2) AND (I.Y = Y2));
if Finished then Break;
TestNeighbours;
end;
until NOT OldWave.Next;
end;
NewWave.Free;
OldWave.Free;
end;
| |
Работает эта функция следующим образом. В OldWave
хранится текущая фаза волны, то есть элементы, движущиеся к своим
клеткам. Момент достижения первыми элементы волны клеток назначения
наступит через OldWave.MinCost. Соответственно мы пробегаем по всем элементам OldWave и вычитаем из их поля Cost значение OldWave.MinCost. Элементы, для которых Cost остался больше нуля, еще не добрались до цели. Они переносятся в NewWave,
формируя следующую фазу волны. Элементы, достигшие цели, помечают свою
клетку как пройденную и начинают движение из этой клетки во всех
возможных еще не пройденных направлениях, записывая новые элементы
также в NewWave. По завершении цикла OldWave и NewWave меняются содержимым, и все повторяется сначала. Если в начале цикла OldWave
пуст, то значит, искомый путь не существует. Если была достигнута
клетка, путь к которой мы искали, то выходим из функции. Путь найден.
Ну, и наконец, три последние функции:
function MakePathMap(
MapWidth,MapHeight : Integer;
StartX,StartY : Integer;
GetCostFunc : TGetCostFunc) : TPathMap;
begin
PathFind.MapWidth:=MapWidth;
PathFind.MapHeight:=MapHeight;
PathFind.GetCostFunc:=GetCostFunc;
Result:=FillPathMap(StartX,StartY,-1,-1);
end;
function FindPathOnMap(PathMap : TPathMap; X,Y : Integer) : TPath;
var
Direction : Integer;
begin
Result:=NIL;
if PathMap[Y,X].Distance < 0 then Exit;
SetLength(Result,PathMap[Y,X].Distance+1);
while PathMap[Y,X].Distance > 0 do
begin
Result[PathMap[Y,X].Distance]:=Point(X,Y);
Direction:=PathMap[Y,X].Direction;
X:=X-DirToDX(Direction);
Y:=Y-DirToDY(Direction);
end;
Result[0]:=Point(X,Y);
end;
function FindPath(
MapWidth,MapHeight : Integer;
StartX,StartY,StopX,StopY : Integer;
GetCostFunc : TGetCostFunc) : TPath;
begin
PathFind.MapWidth:=MapWidth;
PathFind.MapHeight:=MapHeight;
PathFind.GetCostFunc:=GetCostFunc;
Result:=FindPathOnMap(FillPathMap(StartX,StartY,StopX,StopY),StopX,StopY);
end;
| |
Тут все понятно. Дополнительных пояснений не требуется.
Несколько слов о демонстрационной программе. У
программы есть два режима: режим редактирования карты и режим поиска
пути. Для включения режима редактирования карты, нужно нажать одну из
кнопок соответствующих определенному типу территории. Стоимость
движения по территории данного типа указывается в скобках следом за
названием (см. рис.).
Стоимость движения указана для движения по горизонтали
или по вертикали. Стоимость движения по диагонали в полтора раза
больше. Для препятствий стоимость движения не указывается. Она всегда
равна –1. При нажатой кнопке типа территории клик мышки на определенную
клетку карты назначает этой клетке соответствующий тип территории. При
нажатии кнопки Случайная карта каждой клетке тип
территории выбирается случайным образом равновероятно. На карту это
похоже мало, но посмотреть, как работает поиск пути, вполне годится. К
тому же можно результат поправить, не отходя от кассы.
При нажатии кнопки Поиск пути, программа
переключается в соответствующий режим. При этом клик мышки на клетку
карты назначает эту клетку начальной точкой пути. При последующем
сдвиге мышки, программа прорисовывает маршрут из начальной точки, к
клетке, на которую указывает мышь. Повторный клик фиксирует выбранный
путь. Программа переходит в режим ожидания ввода следующей начальной
точки.
Данный модуль может быть применен для поиска
кратчайшего пути в различных игровых моделях. Подразумевается, что
карта прямоугольная с квадратной сеткой и задается двумерным массивом.
Индексация массива должна начинаться с нуля. Размеры произвольные.
За счет передачи в модуль функции, задающей стоимость
движения, можно реализовать поддержку для игровых моделей с различными
законами движения.
Например, задав стоимость движения по диагонали в –1
(препятствие), а стоимость движения по горизонтали и вертикали в 1 (вне
зависимости от типа клетки) мы получим модель, реализованную в
программе Вадима Кеглеса, которая упоминалась выше.
Используя возможность изменять стоимость движения в
зависимости от направления движения, можно, допустим, реализовать поиск
пути на карте с рельефом (типа, Старкрафт), когда две соседние клетки
не являются препятствиями, однако находятся на разных уровнях, и
попасть с одной на другую можно только через пологий подъем, который
расположен в стороне. Можно сделать односторонне проходимые клетки для
какой-нибудь очень уж экзотической фантастической модели.
Если в игровой модели есть несколько типов движущихся
объектов, различающихся своими законами движения, то для каждого типа
можно задать свой вариант функции. Соответственно, возможна поддержка
игровых моделей с различными типами объектов.
Это то, что я смог придумать сходу. Желающие могут продолжить сей список. Дерзайте.
К материалу прилагаются файлы:
[Задачи оптимизации] [Программирование игр.]
Обсуждение материала [ 23-01-2011 22:37 ] 53 сообщения |