Упрощённый алгоритм Прима для генерации идеальных лабиринтов

Лабиринты являются неотъемлемой частью игровой индустрии практически с момента её зарождения. Дебютным проектом, в котором была реализована процедурная генерация уровней, стала Beneath Apple Manor, вышедшая в 1978 году. В ней использовался простейший метод разделения на комнаты и коридоры, что зачастую приводило к созданию монотонных и легко предугадываемых структур, снижавших интерес к игровому процессу. Стремясь к большей органичности, разработчики обратились к алгоритмам теории графов. В данном материале мы разберем способы построения «идеального» лабиринта, взяв за основу алгоритм Прима.

Что представляет собой идеальный лабиринт?

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

стена

проход

проход

стена

Принцип работы алгоритма Прима

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

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

Первый вариант реализации

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


class Maze:
    # Возможные направления движения: по сторонам и по диагонали
    side_directions = [[0, 1], [0, -1], [1, 0], [-1, 0]]
    corner_directions = [[1, 1], [1, -1], [-1, -1], [-1, 1]]

    def __init__(self, n: int, m: int):
        self.n = n
        self.m = m
        # 0 — стена, 1 — проход
        self.cell_status = tuple([0 for _ in range(m)] for _ in range(n))
        # Точка входа
        self.cell_status[0][0] = 1
        # Счетчик соседних клеток пути для предотвращения циклов
        self.cnt_side_neighbours = tuple([0 for _ in range(m)] for _ in range(n))

    def update_neighbours(self, cell: tuple[int, int], neighbours: set):
        for direction in self.side_directions:
            new_cell = (cell[0] + direction[0], cell[1] + direction[1])
            if not self.in_field(new_cell): continue
            self.cnt_side_neighbours[new_cell[0]][new_cell[1]] += 1
            neighbours.add(new_cell)

    def in_field(self, cell: tuple[int, int]):
        return 0 <= cell[0] < self.n and 0 <= cell[1] < self.m

    def is_placement_ok(self, cell: tuple[int, int]):
        # Проверка диагональных соседей для соблюдения структуры
        for direction in self.corner_directions:
            new_cell = (cell[0] + direction[0], cell[1] + direction[1])
            if not self.in_field(new_cell): continue
            # Предотвращение формирования "шахматных" узлов
            side_cell_1 = (new_cell[0], cell[1])
            side_cell_2 = (cell[0], new_cell[1])
            if self.cell_status[new_cell[0]][new_cell[1]] == 1 and \
                    self.cell_status[side_cell_1[0]][side_cell_1[1]] == 0 and \
                    self.cell_status[side_cell_2[0]][side_cell_2[1]] == 0:
                return False
        return True

    def Prim(self):
        # Список доступных для расширения кандидатов
        neighbours = {(0, 0)}
        self.update_neighbours((0, 0), neighbours)
        while neighbours:
            cell = neighbours.pop()
            if not self.in_field(cell) or self.cell_status[cell[0]][cell[1]] == 1:
                continue
            
            # Если добавление клетки создаст цикл или нарушит "идеальность"
            if not self.is_placement_ok(cell) or self.cnt_side_neighbours[cell[0]][cell[1]] >= 2:
                continue
                
            # Валидация пройдена: добавляем клетку в структуру пути
            self.cell_status[cell[0]][cell[1]] = 1
            self.update_neighbours(cell, neighbours)

    def visuialise(self):
        for row in self.cell_status:
            for cell in row:
                print("0" if cell == 1 else "1", end="")
            print()


def main():
    n, m = map(int, input().split())
    maze = Maze(n, m)
    maze.Prim()
    maze.visuialise()


if __name__ == '__main__':
    main()
Визуализация генерации 51x51 (светлые участки — проходы, темные — стены)
Визуализация генерации 51×51 (светлые участки — проходы, темные — стены)

Второй вариант реализации

Этот метод демонстрирует более высокую производительность, хотя его логика несколько сложнее. Мы расширяем исходную область до размера (a+2)x(b+2), где клетки с нечетными координатами назначаются опорными вершинами графа. Задача сводится к связыванию этих вершин. Стартуя из точки [1][1], мы на каждом этапе выбираем случайное неиспользованное ребро, ведущее к еще не посещенной вершине. Результат считывается в границах от [1][1] до [a][b]. Ключевое преимущество — высокая вариативность уровней благодаря элементу случайности при выборе путей.

import random

class Edge:
    """Объект ребра, связывающий родительскую ячейку с целевой."""
    def __init__(self, parent_i, parent_j, cur_i, cur_j):
        self.parent_i = parent_i
        self.parent_j = parent_j
        self.cur_i = cur_i
        self.cur_j = cur_j

a, b = map(int, input().split())
labyrinth = []

# Формирование сетки: нечетные клетки — потенциальные пути, остальные — стены
for i in range(a + 2):
    row = []
    for j in range(b + 2):
        row.append(0 if i % 2 == 1 and j % 2 == 1 else 1)
    labyrinth.append(row)

visited = [[False for _ in range(b + 2)] for _ in range(a + 2)]
visited[1][1] = True
front = []

# Инициализация начальных ребер от стартовой точки
if 3 < b + 2: front.append(Edge(1, 1, 1, 3))
if 3 < a + 2: front.append(Edge(1, 1, 3, 1))

while front:
    # Выбор случайного ребра для обеспечения уникальности лабиринта
    idx = random.randint(0, len(front) - 1)
    front[idx], front[-1] = front[-1], front[idx]
    edge = front.pop()

    if visited[edge.cur_i][edge.cur_j]:
        continue

    visited[edge.cur_i][edge.cur_j] = True
    
    # "Прорубаем" проход между вершинами
    mid_i = (edge.parent_i + edge.cur_i) // 2
    mid_j = (edge.parent_j + edge.cur_j) // 2
    labyrinth[mid_i][mid_j] = 0

    # Проверка границ и добавление новых ребер в очередь
    if 0 < edge.cur_i < a + 1 and 0 < edge.cur_j < b + 1:
        for di, dj in [(0, 2), (0, -2), (2, 0), (-2, 0)]:
            ni, nj = edge.cur_i + di, edge.cur_j + dj
            if 0 <= ni < a + 2 and 0 <= nj < b + 2 and not visited[ni][nj]:
                front.append(Edge(edge.cur_i, edge.cur_j, ni, nj))

# Вывод итоговой матрицы
for i in range(1, a + 1):
    print("".join(map(str, labyrinth[i][1:b+1])))
Результат работы оптимизированного алгоритма (белый цвет — доступный путь)
Результат работы оптимизированного алгоритма (белый цвет — доступный путь)

Резюме

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

 

Источник

Читайте также