- PVSM.RU - https://www.pvsm.ru -
Лабиринты использовались в видеоиграх с момента их появления. Первой видеоигрой с процедурно генерируемым лабиринтом была Beneath Apple Manor, выпущенная в 1978 году. Лабиринт в ней генерировался методом деления на комнаты и коридоры, из-за этого лабиринт часто выглядел однообразным и предсказуемым, что портило впечатление от игры. Для того, чтобы лабиринт выглядел естественнее разработчики стали использовать различные алгоритмы на графах. В этой статье мы рассмотрим реализации генерации идеального лабиринта с помощью алгоритма Прима.
Идеальный лабиринт - это лабиринт без циклов и без недостижимых областей. Из этого следует, что из каждой точки существует ровно один путь к любой другой точке, что на языке графов означает, что лабиринт является деревом [1]. Также не должно быть стен и проходов, расположенных следующим образом:
|
стена |
проход |
|
проход |
стена |
Алгоритм Прима находит дерево с минимальной суммой весов рёбер, которое является подграфом изначального графа. Такие деревья называют минимальными остовными.
Алгоритм Прима делит граф на две части (компоненты), на каждом шаге находя ребро с минимальным весом между этими частями, каждый раз присоединяя одну вершину к первой части и удаляя из второй. Алгоритм заканчивается, когда во второй части не остаётся вершин. Изначально первая часть - одна вершина, а вторая - все остальные. В этой статье мы рассмотрим упрощённую версию алгоритма для графов, где все рёбра имеют одинаковый вес.
В данной реализации вершинами являются все клетки лабиринта, однако по ходу алгоритма некоторые могут быть не присоединены к пути, а стать стенами, если их добавление приводит к нарушению условию идеального лабиринта.
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]):
if 0 <= cell[0] < self.n and 0 <= cell[1] < self.m:
return True
return False
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): continue
# Если клетка уже путь - пропускаем
if self.cell_status[cell[0]][cell[1]] == 1: continue
# Если размещение клетки приведёт к нарушению идельности лабиринта - пропускаем
if not self.is_placement_ok(cell): continue
if 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:
# Путь
if cell == 1:
print("0", end="")
# Не путь
else:
print("1", end="")
print()
def main():
n, m = map(int, input().split())
maze = Maze(n, m)
maze.Prim()
maze.visuialise()
if __name__ == '__main__':
main()
Данная реализация быстрее первой, но она труднее в понимании. В этой реализации мы увеличиваем поле a*b до поля (a + 2)*(b+2), а затем присваиваем клеткам с нечётным номером строки и столбца статус вершины. Таким образом у нас появляется вершины графа, которые надо связать. Мы начинаем из вершины [1][1] и каждый шаг выбираем случайное неиспользованное ребро, доступное из вершин, которые уже в дереве. Итоговый ответ будет находится на индексах матрицы [1][1] - [a][b]. Главное преимущество данной реализации - благодаря случайному выбору ребра каждый раз лабиринт получается разный.
import random
class Edge:
"""Класс, хранящий ребро, соединяющее клетку матрицы [parent_i][parent_j] и [cur_i][cur_j]."""
def __init__(self, parent_i: int, parent_j: int, cur_i: int, cur_j: int):
"""
Инициализация ребра.
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): # Заполняем лабиринт стенами (1) и перекрёстками (0)
temp = []
for j in range(b + 2):
if i % 2 == 1 and j % 2 == 1:
temp.append(0)
else:
temp.append(1)
labyrinth.append(temp)
visited = [] # Матрица, хранящая была ли клетка [i][j] уже посещена алгоритмом
for i in range(a + 2):
temp = []
for j in range(b + 2):
temp.append(False)
visited.append(temp)
visited[1][1] = True # Начинаем с вершины 1,1
front = [] # Массив, в котором будут храниться все обрабатываемые рёбра
if 3 < b + 2:
front.append(Edge(1, 1, 1, 3)) # По возможности добавляем соседа клетки 1, 1 справа
if 3 < a + 2:
front.append(Edge(1, 1, 3, 1)) # По возможности добавляем соседа клетки 1, 1 снизу
while len(front) != 0:
random_edge_index = random.randint(0, len(front) - 1) # Выбираем случайное ребро из списка обрабатываемых рёбер
# Для лучшей асимптотики меняем местами выбранное и последнее ребро
front[random_edge_index], front[len(front) - 1] = front[len(front) - 1], front[random_edge_index]
current_edge: Edge = front[len(front) - 1] # Запоминаем выбранное ребро
front.pop() # Удаляем выбранное ребро из списка обрабатываемых рёбер
if visited[current_edge.cur_i][current_edge.cur_j]: # Если вершина уже посещена, заканчиваем обработку этого ребра
continue
visited[current_edge.cur_i][current_edge.cur_j] = True # Отмечаем вершину как посещённую
# Соединяем вершину с её родителем
if current_edge.parent_i > current_edge.cur_i:
labyrinth[current_edge.cur_i + 1][current_edge.cur_j] = 0
elif current_edge.parent_i < current_edge.cur_i:
labyrinth[current_edge.cur_i - 1][current_edge.cur_j] = 0
elif current_edge.parent_j > current_edge.cur_j:
labyrinth[current_edge.cur_i][current_edge.cur_j + 1] = 0
else:
labyrinth[current_edge.cur_i][current_edge.cur_j - 1] = 0
# Если вершина находится в необрабатываемой зоне, заканчиваем обработку ребра
if current_edge.cur_i == 0 or current_edge.cur_i == a + 1:
continue
if current_edge.cur_j == 0 or current_edge.cur_j == b + 1:
continue
# Добавляем в список обрабатываемых рёбер рёбра, ведущие к не посещённым соседям вершины
if current_edge.cur_i + 2 < a + 2 and not visited[current_edge.cur_i + 2][current_edge.cur_j]:
front.append(Edge(current_edge.cur_i, current_edge.cur_j, current_edge.cur_i + 2, current_edge.cur_j))
if current_edge.cur_i - 2 >= 0 and not visited[current_edge.cur_i - 2][current_edge.cur_j]:
front.append(Edge(current_edge.cur_i, current_edge.cur_j, current_edge.cur_i - 2, current_edge.cur_j))
if current_edge.cur_j + 2 < b + 2 and not visited[current_edge.cur_i][current_edge.cur_j + 2]:
front.append(Edge(current_edge.cur_i, current_edge.cur_j, current_edge.cur_i, current_edge.cur_j + 2))
if current_edge.cur_j - 2 >= 0 and not visited[current_edge.cur_i][current_edge.cur_j - 2]:
front.append(Edge(current_edge.cur_i, current_edge.cur_j, current_edge.cur_i, current_edge.cur_j - 2))
for i in range(1, a + 1): # Выводим обрабатываемую зону
for j in range(1, b + 1):
print(labyrinth[i][j], end="")
print()
Мы рассмотрели два подхода к генерации идеальных лабиринтов. Результаты работы таких алгоритмов можно использовать для создания лабиринтов в играх или процедурной генерации уровней на их основе. Надеемся, материал был полезен.
Автор: uzra
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/algoritmy/446006
Ссылки в тексте:
[1] деревом: https://algorithmica.org/tg/dfs
[2] Image: https://sourcecraft.dev/
[3] Источник: https://habr.com/ru/articles/1004900/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1004900
Нажмите здесь для печати.