Пройди свой путь в ширину: библиотеки для графов из инди-игры TESTAMENT

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

Знаете, почему математики не любят направленные нецикличные графы? Потому что если они совершать ошибку, они не смогут вернуться.

С чего все началось

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

Когда я начал работать над своим пет-проектом TESTAMENT, мне нужно было генерировать и отображать произвольные графы. Я хотел, чтобы локации были в виде простых шагов-событий, соединенных между собой. Примерами могут послужить проекты Slay The Spire, Cult of the Lamb, Darkest Dungeon и другие. Исследовав вопрос, я увидел, что существующие библиотеки слишком тяжелые и сложные для моих нужд.

  • QuickGraph — развитая библиотека, которая активно используется в промышленных системах. Наверное, самым мастодонт среди всех библиотек, которые я знаю.

  • GraphShape — форк и последующая переработка заброшенного GraphSharp. Куча алгоритмов раскладки. И скупая документация, кажется, для галочки.

  • Microsoft Automatic Graph Layout — мощное решение от лидера рынка корпоративных решений.

  • YWorks — красивое коммерческое решение.

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

Какими будут мои графы:

  1. Графы направленные. По сути — деревья.

  2. Графы нецикличные.

  3. Может быть несколько стартовых узлов.

  4. Предполагается один финишный узел.

  5. Из одного узла может выходить несколько ребер в разные соседние узлы.

  6. Один узел может принимать несколько ребер от разных соседних узлов.

  7. Все узлы одинакового размера.

  8. Подойдет вариант визуализации, когда граф просто вытянут в линию. Возможно даже, такой вариант наиболее предпочтителен. Чтобы игрок проходил от начала (слева) до конца (направо).

При генерации:

  1. Всегда должно быть несколько стартовых улов.

  2. В определенный момент несколько узлов графа должны входить в один узел.

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

  4. Нужно полностью контроллировать, какими могут быть следущие узлы.

Минимальные графы

Моя библиотека https://github.com/kreghek/CombatDicesTeam.Graphs предоставляет единственный интерфейс для работы с графами IGraph<TNodePayload>, где TNodePayload — любой тип данных, которые будут содержать узлы графа. Интерфейс графа, на самом деле, одноклеточный:

  • AddNode — добавляет узел в граф.

  • ConnectNodes — соединяет два узла ребром.

  • GetAllNodes — возвращает все узлы графа.

  • GetNext — возвращает узлы, соединенные с указанным узлом.

Самая простая и наивная реализация для ориентированных графов — DirectedGraph.

Пример использования. Введем свой тип данных. У меня в игре это, например, граф локаций. Локации можно описать примерно так.

interface ILocationFactory
{
    // Какие-то методы предоставления информации о локации
    // и конструирования игровых объектов.
}
///<summary>
/// В этой локации будет сражение героев с монстрами.
///</summary>
sealed class CombatLocationFactory: ILocationFactory
{
}
///<summary>
/// В этой локации с героями произойдет случайное событие.
///</summary>
sealed class EventLocationFactory: ILocationFactory
{
}
///<summary>
/// В этой локации герои смогут восстановиться.
///</summary>
sealed class RestLocationFactory: ILocationFactory
{
}
///<summary>
/// Это конечная локация с наградой.
///</summary>
sealed class RewardLocationFactory: ILocationFactory
{
}

Создадим простой нецикличный граф вроде такого. Игрок должен начать бой. Затем у него есть выбор — легкий или сложный путь. И награда в конце.

Рисунок 1. Пример графа
Рисунок 1. Пример графа
// 1. Создаем объект графа.
var graph = new DirectedGraph<ILocationFactory>();
// 2. Создаем узлы графа
var combatNode = new GraphNode<ILocationFactory>(new CombatLocationFactory());
graph.AddNode(combatNode);
var restNode = new GraphNode<ILocationFactory>(new RestLocationFactory());
graph.AddNode(restNode);
var eventNode = new GraphNode<ILocationFactory>(new EventLocationFactory());
graph.AddNode(eventNode);
var extraCombatNode = new GraphNode<ILocationFactory>(new CombatLocationFactory());
graph.AddNode(extraCombatNode);
var rewardNode = new GraphNode<ILocationFactory>(new RewardLocationFactory());
graph.AddNode(rewardNode);
// 3. Соединяем узлы графа ребрами.
// Легкий путь к спасению.
graph.ConnectNodes(combatNode, restNode);
graph.ConnectNodes(restNode, rewardNode);
// Путь железного человека ради славы и опыта.
graph.ConnectNodes(combatNode, eventNode);
graph.ConnectNodes(eventNode, extraCombatNode);
graph.ConnectNodes(extraCombatNode, rewardNode);

Раскладка графа

Алгоритм

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

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

BFS (Breadth First Search) — выполняет обход поуровнево: сначала проходит по всем ближайшим от начальной точки вершинам, потом спускается глубже

Получается массив массивов.

Рисунок 2. Поуровневое представление графа в памяти
Рисунок 2. Поуровневое представление графа в памяти

Далее нам нужно выдать координаты каждому узлу. Для получения x-координаты просто перемножаем уровень на ширину узла. Для получения y-координаты нам нужно индекс узла умножить на высоту узла. А так же нужно выровнять узел по центру по высоте.

Рисунок 3. Выравнивание узлов графа по высоте
Рисунок 3. Выравнивание узлов графа по высоте

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

Но тут есть нюанс. По контракту, граф не гарантирует порядок соседних узлов в методе GetNext().

Рисунок 4. Проблема с пересечением ребер
Рисунок 4. Проблема с пересечением ребер

Для того, чтобы «сгладить» эту проблему, выполним следующую доработку:

  1. Назначаем вес корневым узлам. Вес назначается по порядку.

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

  3. Упорядочиваем узлы по весу в рамках одного уровня.

  4. Рекурсивно повторяем для всех уровней.

Весь алгоритм в UML:

Рисунок 5. Алгоритм раскладки в UMLv
Рисунок 5. Алгоритм раскладки в UMLv

Реализация

Библиотека https://github.com/kreghek/CombatDicesTeam.Graphs.Layout содержит реализацию этого алгоритма. Разберем, как раскатать этот граф в плоскости.

// 1. Создаем визуализатор. Будет работать с графами целых чисел.
// Этот визуализатор раскладывает граф слева-направо в одну линию.
var visualizer = new HorizontalGraphVisualizer<ILocationFactory>();
// 2. Получаем разметку графа.
var config = new ConcreteLayoutConfig();
var layouts = visualizer.Create(campaignGraph, config);

Визуализатор возвращает набор объектов типа IGraphNodeLayout<TNodePayload> 

 с данным для отображения каждого узла.

  • Node — ссылка на исходный узел графа.

  • Position — это целочисленные координаты (X, Y), где нужно отрисовать узел.

  • Size — это размер узла. Сейчас значения для всех узлов будут одинаковыми.

Пример конфига для визуализатора:

sealed class ConcreteLayoutConfig : ILayoutConfig
{
    private const int LAYOUT_NODE_SIZE = 32;
    private const int CONTENT_MARGIN = 5;
    
    public int NodeSize => LAYOUT_NODE_SIZE + CONTENT_MARGIN * 2;
}

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

  • RetryTransformLayoutPostProcessor — Выполняет произвольную трансформацию узла графа и валидирует изменения. Если изменения не валидны, то повторяет трансформацию. Идея в том, чтобы случайно подвигать узлы графа. Но если узел после перемещения начинает пересекать другие узлы, то попробовать выбрать другую случайную позицию.

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

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

  • RotatePostProcessor — Поворачивает граф на указанный угол в радианах вокруг начала координат. В начале координат будет самый первый корень. В моей игре я поворачиваю на случайный угол от 0 до Pi. По соображениям Ui/UX. Чтобы для игрока движение по графу все еще было слева-направо сверху-вниз.

Вот как это используется у меня:

// Создаем свою реализацию трансформаций узлов

class RandomPositionLayoutTransformer : IGraphNodeLayoutTransformer<ILocationFactory>
{
    private readonly Random _random;
    private readonly int _offsetDistance;

    public RandomPositionLayoutTransformer(Random random, int offsetDistance)
    {
        _random = random;
        _offsetDistance = offsetDistance;
    }

    /// <inheritdoc />
    public IGraphNodeLayout<ILocationFactory> Get(IGraphNodeLayout<ILocationFactory> layout)
    {
        // Calculate new random position of layout.
        var offset = new Position(_random.Next(-_offsetDistance, _offsetDistance), _random.Next(-_offsetDistance, _offsetDistance));
        var position = new Position(layout.Position.X + offset.X, layout.Position.Y + offset.Y);

        // And create new layout.
        // If new generated position is not valid it will be failed by validation above.
        return new GraphNodeLayout<ILocationFactory>(layout.Node, position, layout.Size);
    }
}

Собственно, главная сцена. Исказим стройный граф!

// 1. Создаем конвейер пост-процессоров

var random = new Random();

const int TRANSFORM_REPEAT_COUNT = 5;
const int TRANSFORM_RETRY_COUNT = 10;
var postProcessors = new ILayoutPostProcessor<ILocationFactory>[]
{
    // Увеличиваем расстояние между узлами.
    new PushHorizontallyPostProcessor<ILocationFactory>(config.NodeSize / 2),
    // Поворачиваем весь граф на произвольный угол.
    new RotatePostProcessor<ILocationFactory>(random.NextDouble() * Math.PI),
    // Следующие трансформации будут выполняться несколько раз.
    new RepeatPostProcessor<ILocationFactory>(TRANSFORM_REPEAT_COUNT,
        // Пробуем сдвинуть узел в слечайном направлении через нашу реализацию трасформаций.
        // Если узел начал пересекать другие узлы после сдвига, то повторяем попытку сдвинуть.
        new RetryTransformLayoutPostProcessor<ILocationFactory>(
	    new RandomPositionLayoutTransformer(random, config.NodeSize / 2),
            new IntersectsGraphNodeLayoutValidator<ILocationFactory>(), TRANSFORM_RETRY_COUNT))
};

// 2. Обрабатываем набор узлов.
foreach (var postProcessor in postProcessors)
{
    layouts = postProcessor.Process(layouts);
}

// 3. Отрисовываем узлы. Тут уже ваш код.
foreach (var layout in layouts)
{
    // Используй:
    // - layout.Position.X и layout.Position.Y как координаты, где нужно отрисовывать узел.
    // - layout.Size.Width и layout.Size.Height чтобы получить размер узла в пикселях.
    // - layout.Node.Payload чтобы прочитать данные узла. В нашм случае это будут целые числа 1-4, которые мы задали при создании графа.
}

Генерация графов

Генерация графов — это настоящее творчество. Чтобы локации были интереснее, мне нужно создать уникальные и интересные графы. Чтобы игроку каждый раз было интересно планировать новый маршрут. Но чтобы было больше контроля над локацией, нужно использовать какие-то шаблонные решения. Развивая эту идею, можно создать шаблон основных путей графа. По сути, это будет тоже направленный граф, только более высокого порядка. Алгоритм работы прост: мы обходим граф путей, материализуем шаблоны в конкретные узлы и соединяем начальные и конечные узлы ребрами. Поехали!

Реализация

Для генерации используется библиотека https://github.com/kreghek/CombatDicesTeam.Graphs.Generation.

Все начинается с создания путей. Чтобы создать путь, нужно создать экземпляр GraphWay. Путь — это набор шаблонов, которые являются реализаций интерфейса IGraphTemplate.

Пример:

sealed class CombatLocationTemplate: IGraphTemplate<ILocationFactory>
{
    IGraphNode<ILocationFactory> Create(IGraphTemplateContext<ILocationFactory> context)
    {
        // Создаем узел итогового графа
	return new GraphNode<ILocationFactory>(new CombatLocationFactory());
    }

    bool CanCreate(IGraphTemplateContext<ILocationFactory> context)
    {
        // Этот метод может быть использован в других шаблонах.
        return true;
    }
}

///<summary>
/// Выбирает один случайный шаблон или указанный шаблон, если случайный нельзя использовать.
///</summary>
sealed class RandomLocationTemplate: IGraphTemplate<ILocationFactory>
{
    private readonly Random _random;
    private readonly IGraphTemplate<ILocationFactory> _fallbackTemplate;
    private readonly IGraphTemplate<ILocationFactory>[] _availableTemplates;

    public RandomLocationTemplate(Random random, IGraphTemplate<ILocationFactory> fallbackTemplate, params IGraphTemplate<ILocationFactory>[] availableTemplates)
    {
        _random = random;
	_fallbackTemplate = fallbackTemplate;
        _availableTemplates = availableTemplates;
    }
    
    IGraphNode<ILocationFactory> Create(IGraphTemplateContext<ILocationFactory> context)
    {
        var rolledIndex = _random.Next(0, _availableTemplates.Length - 1);
	var rolledTemplate = _availableTemplates[rolledIndex];
	
	if (!rolledTemplate.CanCreate(context))
	{
	    return _fallbackTemplate.Create(context);
	}
	
        // Создаем узел итогового графа
	return rolledTemplate.Create(context);
    }

    bool CanCreate(IGraphTemplateContext<ILocationFactory> context)
    {
        // Этот метод может быть использован в других шаблонах.
        return true;
    }
}

Далее формируем граф путей

public IGraph<GraphWay<ILocationData>> CreateWaysGraph()
{
    var random = new Random();
    var wayGraph = new Graph<GraphWay<ILocationFactory>>();

    var way1Templates = new IGraphTemplate<ILocationFactory>[]
    {
        new CombatLocationTemplate(),
        new RestLocationTemplate(),
        new CombatLocationTemplate()
    };

    var way2Templates = new IGraphTemplate<ILocationFactory>[]
    {
        new CombatLocationTemplate(),
        new RandomLocationTemplate(random, new RestLocationTemplate(), new CombatLocationTemplate(), new EventLocationTemplate()),
    };

    var way3Templates = new IGraphTemplate<ILocationFactory>[]
    {
        new CombatLocationTemplate(),
        new RestLocationTemplate()
    };

    var regular1Way = new GraphWay<ILocationFactory>(way1Templates);
    var way11Node = new GraphNode<GraphWay<ILocationFactory>>(regular1Way);
    var way12Node = new GraphNode<GraphWay<ILocationFactory>>(regular1Way);
    var way13Node = new GraphNode<GraphWay<ILocationFactory>>(regular1Way);

    var regular2Way = new GraphWay<ILocationFactory>(way2Templates);
    var way2Node = new GraphNode<GraphWay<ILocationFactory>>(regular2Way);

    var regular3Way = new GraphWay<ILocationFactory>(way3Templates);
    var way31Node = new GraphNode<GraphWay<ILocationFactory>>(regular3Way);
    var way32Node = new GraphNode<GraphWay<ILocationFactory>>(regular3Way);

    var rewardNode = new GraphNode<GraphWay<ILocationFactory>>(new GraphWay<ILocationFactory>(new[]
    {
    	new RewardLocationFactory(_services)
    }));

    wayGraph.AddNode(way11Node);
    wayGraph.AddNode(way12Node);
    wayGraph.AddNode(way13Node);

    wayGraph.AddNode(way2Node);

    wayGraph.ConnectNodes(way11Node, way2Node);
    wayGraph.ConnectNodes(way12Node, way2Node);
    wayGraph.ConnectNodes(way13Node, way2Node);

    wayGraph.AddNode(way31Node);
    wayGraph.AddNode(way32Node);

    wayGraph.ConnectNodes(way2Node, way31Node);
    wayGraph.ConnectNodes(way2Node, way32Node);

    wayGraph.AddNode(rewardNode);

    wayGraph.ConnectNodes(way31Node, rewardNode);
    wayGraph.ConnectNodes(way32Node, rewardNode);

    return wayGraph;
}

И, наконец, создаем граф по шаблону.

var wayGraph = CreateWaysGraph();
var graphGenerator = new TemplateBasedGraphGenerator<ILocationData>(new TemplateConfig<ILocationData>(wayGraph));
var worldGraph = graphGenerator.Create();

Далее worldGraph можно визуализировать так, как я рассказал в статье выше.

Резюме

Рисунок 5. Пример использования библиотек
Рисунок 5. Пример использования библиотек

Я знаю, что код не идеален. Сейчас он решает очень узкую задачу. Но этот код используется в игре TESTAMENT. Для работы с локациями (о чем была статья), для дерева навыков и рецептов крафта. А если потребуется более серьезная работа с графами, лучше использовать один из инструментов выше.

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


Источник: https://habr.com/ru/articles/741478/


Интересные статьи

Интересные статьи

Привет! Меня зовут Дима, я Java-разработчик. Хочу рассказать как я пришел в профессию, вырос до мидла и перешел из госучреждений в аутсорс компанию по разработке приложений. Рассказ будет полезен джун...
Не все тестовые задания удостаиваются внимания на Хабре. Почему — примерно, понятно. Однако бывают исключения. Так, некоторое время тому назад одно, в общем-то, ничем не примечательное тестовое зада...
В те далёкие времена, когда я впервые столкнулся со свойством CSS clip-path, мне потребовалось больше времени, чем я ожидал, и я изо всех сил старался запомнить, как работает свойство. Не...
Одним вечером, после очередного расстраивающего дня, наполненного попытками наладить баланс в своей игре, я решил, что мне срочно требуется отдых. Переключусь на другой проект, быстренько его сде...