Создание случайного связанного лабиринта

Автор
avatar-kromkokromko
Notebook

Создание случайного связанного лабиринта

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

Я использовал некоторые функции Julia, которые не были представлены на Уроках, но буду оставляю комментарии. Возможно, кому-то будет интересно.

Я буду вводить используемые мной модули по мере востребования.

Создание стен

Представим нашу прямоугольную комнату, состоящую из тайлов. Каждый тайл может быть либо пустым пространством, либо стеной. Я предлагаю сделать тайл в виде исчисляемого типа (enum). В Julia это реализовано при помощи макроса @enum

In [ ]:
@enum Tile Empty Wall
typeof(Wall) # выводит тип данных
Out[0]:
Enum Tile:
Empty = 0
Wall = 1

Хорошо. Давайте создадим пустую комнату с окружающими стенами.

In [ ]:
function generateroom(w, h)
    room = fill(Empty, (h, w))
    room[begin, :] .= room[end, :] .= Wall
    room[:, begin] .= room[:, end] .= Wall
    room
end
Out[0]:
generateroom (generic function with 1 method)

И отрисуем её. Я создам словарь с палитрой для тайлов

In [ ]:
using Images, Colors

function drawtiles(room::Matrix{Tile})
    map(e -> tilecolors[e], room)
end

tilecolors = Dict(Empty => RGB(0,0,0), Wall => RGB(0,0.8,0))
Out[0]:
Dict{Tile, RGB} with 2 entries:
  Empty => RGB{N0f8}(0.0,0.0,0.0)
  Wall  => RGB{Float64}(0.0,0.8,0.0)
In [ ]:
let
    # блок let создаёт локальное пространство переменных, т.е. этот room доступен только внутри него
    room = generateroom(60, 40)
    drawtiles(room)
end
Out[0]:
No description has been provided for this image

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

In [ ]:
CI = CartesianIndex

function generateroom(w, h; walls=Returns(Empty))
    indices = CI(1,1):CI(h,w) # индексы 2-мерной матрицы размером h на w.
    room = map(walls, indices)
    room[begin, :] .= room[end, :] .= Wall
    room[:, begin] .= room[:, end] .= Wall
    room
end
Out[0]:
generateroom (generic function with 1 method)

Немного объяснений. Я добавил именной аргумент walls, и ожидаю, что он будет принимать функцию с одним аргумент, которая возвращает либо Empty либо Wall. Значение по-умолчанию, Returns(Empty) эквивалентно функции x -> Empty, т.е. по умолчанию все внутренние тайлы будут пустыми.

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

In [ ]:
using Distributions

randomwall(p_wall) = idx -> ifelse(rand(Bernoulli(p_wall)), Wall, Empty)

drawtiles(generateroom(60, 40; walls=randomwall(0.7)))
Out[0]:
No description has been provided for this image

Внезапно комната стала чуть более тесной.

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

В качестве распределения я использовал распределение Бернулли. С вероятностью p случайное значение будет истинным, а с вероятностью 1-p - ложным. Функция ifelse здесь заменяет тернарный оператор. Они не совсем 1:1 эквивалентны, но в моём случае это не имеет значения.

Итак, randomwall принимает в качестве аргумента вероятность, и возвращает функцию от одного аргумента (игнорируя этот аргумент), которая в свою очередь возвращает случайно Wall или Empty.

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

In [ ]:
function fillcircle(origin::CI{2}, r::Real; inside=Returns(Wall))
	function f(idx)
		d = origin - idx
		return euclid(d)  r ? inside(idx) : Empty
	end
end

euclid(a::CI) = norm(Tuple(a), 2)
euclid(a, b) = euclid(b - a)
taxicab(a::CI) = Int(norm(Tuple(a), 1))
taxicab(a, b) = taxicab(b - a)
Out[0]:
taxicab (generic function with 2 methods)

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

Далее я добавил несколько норм - евклидову, которая пригодится здесь, и метрику городских кварталов, которую я буду использовать позднее. Функция norm является стандартной, и считает норму n-го порядка для вектора. Мне пришлось превратить CI в кортеж, поскольку в Julia принудительно нельзя производить итерацию по координатам в декартовом индексе.

Скомпонуем эти функции:

In [ ]:
room = generateroom(60, 40;
    walls=fillcircle(CI(30, 30), 25; inside=randomwall(0.8))
)
# при создании комнаты я сначала указываю ширину, а потом высоту
# а индекс CI(строка, столбец), т.е. наоборот
drawtiles(room)
Out[0]:
No description has been provided for this image

Меня вполне устраивает такая фигура. Те, кому интересно, могут поэкспериментировать сами.

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

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

  1. Ищется неразмеченный свободный тайл (если таких нет, значит всё готово).
  2. Этот тайл отмечается как принадлежащий сегменту с номером n. Его соседи тоже отмечаются как часть этого сегмента, как и их соседи.
  3. Как только соседи закончились, возвращаемся к п.1.

К сожалению, на этом этапе я вынужнен расстаться с enum. Сегментированная комната будет в виде матрицы целых чисел. Прошу не обращать внимание на аргумент callback, я буду использовать его после. По умолчанию он ничего не делает.

In [ ]:
function floodfill(room::Matrix{Tile}; callback=Returns(nothing))
    # сделаем из Wall -1, а из Empty 0 
	segmentedroom = map(t -> ifelse(iswall(t), -1, 0), room)
	callback(segmentedroom)
	segmentnumber = 1
	while true
		unassignedtile = findfirst(t -> t == 0, segmentedroom)
		if isnothing(unassignedtile) break end
		# но можно сразу `return segmentedroom` вместо break
		fillin!(segmentedroom, segmentnumber, unassignedtile; callback) # заливаем сегмент начиная с найденного тайла
		segmentnumber += 1
	end
	segmentedroom
end
Out[0]:
floodfill (generic function with 1 method)
In [ ]:
# вспомогательные функции для удобства
iswall(t::Integer) = t == -1
iswall(t::Tile) = t == Wall
iswall(i::CI, room) = iswall(room[i])
Out[0]:
iswall (generic function with 3 methods)

Собственно, сам перебор соседей можно реализовать при помощи типа данных Очередь (как список, но данные добавляются в конец, а извлекаются с начала). Стандатный Vector в Julia довольно универсален, и для него определены нужные фунции.

В этой функции я в качестве именного аргумента добавил empty - это то значение (по умолчанию 0), которое мы заменяем номером сегмента. Опять же, игнорируйте callback.

In [ ]:
function fillin!(segmentedroom::AbstractMatrix, segmentnumber::Integer, starttile::CartesianIndex{2}; empty=0, callback=Returns(nothing))
	# указывать типы данных необязательно, это больше для читателя
	queue = [starttile]
	while length(queue) > 0
		currenttile = popfirst!(queue)
		if segmentedroom[currenttile] != empty continue end
		# если это стена, или тайл уже ассоциирован с сегментом, пропускаем
		segmentedroom[currenttile] = segmentnumber
		callback(segmentedroom)
		neighbours = getneighbours(currenttile)
		push!(queue, neighbours...)
	end
	segmentedroom
end
Out[0]:
fillin! (generic function with 1 method)

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

In [ ]:
getneighbours(tile) = map(n -> n+tile, [CI(0,1), CI(1,0), CI(0,-1), CI(-1,0)])
getneighbours(predicate, tile) = filter(predicate, getneighbours(tile))
Out[0]:
getneighbours (generic function with 2 methods)

Вторая функция нам может не пригодиться, но её можно использовать, если у нас есть критерий, по которому мы хотим отобрать соседей (например, не-стены). Я мог использовать её в fillin!, но я все равно там делаю ещё одну проверку.

И, в общем-то всё. Давайте нарисуем. Но сначала добавим палитру.

In [ ]:
fillcolors = distinguishable_colors(10, collect(values(tilecolors)), dropseed=true)
Out[0]:
No description has been provided for this image
In [ ]:
function drawsegments(segments; scale=1)
	function tocolor(s)
		if s == 0 return tilecolors[Empty] end
		if s == -1 return tilecolors[Wall] end
		return fillcolors[rem(s-1, length(fillcolors)) + 1]
	end
	img = map(tocolor, segments)
	repeat(img, inner=(scale, scale)) # на случай если мы захотим увеличить размер картинки путём повторения пикселей
end
Out[0]:
drawsegments (generic function with 1 method)
In [ ]:
let
    segmentedroom = floodfill(room)
    nsegments = filter(!iswall, segmentedroom)
    println("Количество найденных сегментов: $(length(unique(nsegments)))")
    
    drawsegments(segmentedroom)
end
Количество найденных сегментов: 166
Out[0]:
No description has been provided for this image

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

In [ ]:
using Base64
function display_webm(filename)
    display("text/html", string("""<video autoplay controls><source src="data:video/x-m4v;base64,""",
    base64encode(open(read,filename)),"""" type="video/webm"></video>"""))
end
Out[0]:
display_webm (generic function with 1 method)
In [ ]:
#добавлю возможность масштабировать картинку тайлов
drawtiles(room; scale) = repeat(drawtiles(room), inner=(scale, scale))

using VideoIO
let
    scale = 6
    encoder_options = (crf=23,)
    framerate=30
    path = joinpath(@__DIR__, "fill1.webm")

    initial = drawtiles(room; scale)

    # VideoIO принимает не все RGB форматы, а только RGB{N0f8}
    initial = Matrix{RGB{N0f8}}(initial)

    open_video_out(path, initial, framerate=framerate, encoder_options=encoder_options) do writer

        cb(segments) = write(writer, Matrix{RGB{N0f8}}(drawsegments(segments; scale=scale)))

	    # а вот и callback понадобился
	    seg = floodfill(room; callback=cb)
    end

    display_webm(path)
end

Обратите внимание, я использовал замыкание callback, чтобы отрисовывать изображение. floodfill не знает ничего, что делает callback.

Ломать - не строить!

С большой вероятностью (которая зависит от параметра p_wall, использованного при генерации заполненного стенами круга), результат заливки показал наличие нескольких сегментов. Поскольку изначально я задал необходимость создания связного лабиринта, перейдём к более весёлой части: обратное строительство.

Исходил из следующих соображений:

  1. Если у нас больше одного сегмента в комнате, я хочу сломать стену которая ближе всего к двум сегментам.
  2. Я не хочу перебирать всё изображение несколько раз в поиске самой "тонкой" стены. Имеет смысл начать с самого маленького сегмента, и искать сначала около него.
  3. При оценке потециального кандидата на снос, я опять же не хочу перебирать все возможные тайлы в поиске ближайших. Разумно начать с ближайших соседей и идти по нарастанию расстояния.
In [ ]:
function joinsegments!(room::AbstractMatrix{T}; callback=Returns(nothing)) where T<:Integer
	# ограничение на матрицу целых чисел для того, чтобы случайно не подсунуть матрицу из тайлов

	while true
		allsegments = findsegments(room) # см.далее
		if length(allsegments) == 1 return room end
		
		# ищу самый маленький сегмент. Итерация по словарю выдаёт пару `k => v` (s[2] как раз ссылается на v)
		# s[2] тут кортеж с именными элементами (см. allsegments)
		smallest = argmin(s -> s[2].size, allsegments)[1]

		# начнём с центра сегмента и будем искать самую уязвимую стену, см.далее
		weakest = findweakwall(room, smallest, allsegments[smallest].center)

		neigh = getneighbours(n -> !iswall(n, room), weakest)
		neighseg = unique(map(n->room[n], neigh))

		if length(neighseg) == 0
			# если эта стена оказалась вдали от всех сегментов, сделаем её новым сегментов
			newseg = maximum(keys(allsegments))+1
			room[weakest] = newseg
		elseif length(neighseg) == 1
			# если стена была рядом с одним сегментом, присоединим её ему
			room[weakest] = neighseg[1]
		else
			# а здесь случай, когда эта стена разделяет два или более сегментов
			neighseg = sort(neighseg; by=n->allsegments[n].size, rev=true)
			biggest = popfirst!(neighseg)
			# я воспользую здесь уже существующим алгоритмом заливки чтобы сделать анимацию в конце
			# но room[room .== n] .= biggest скорее всего было бы эффективнее
			for n in neighseg
				room[weakest] = n
				fillin!(room, biggest, weakest; empty=n, callback)
			end
		end
		callback(room)
	end
	room
end
Out[0]:
joinsegments! (generic function with 1 method)

В поиске сегментов, я нахожу их номер, размер, а также координаты центра этого сегмента, и выдаю в качестве Dict.

In [ ]:
function findsegments(room)
	centers = Dict{Int, CI{2}}()
	sizes = Dict{Int, Int}()
	
	for (idx, tile) in pairs(room)
		if iswall(tile) continue end

		if !haskey(centers, tile)
			centers[tile] = idx
			sizes[tile] = 1
		else
			centers[tile] += idx
			sizes[tile] += 1
		end
	end

	# объединим в один кортеж
	middle(idx::CI, n) = CI(round.(Int, Tuple(idx) ./ n)...)
	
	Dict((k => (center=middle(centers[k], sizes[k]), size=sizes[k]) for k in keys(centers))...)
end
Out[0]:
findsegments (generic function with 1 method)

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

Первое что я делаю - задаю себе ограничение, что я не ищу намного дальше расстояния до самого дальнего тайла моего сегмента.

Я также ввожу вспомогательную функцию isinner, чтобы не анализировать тайлы вне комнаты.

Поскольку я не знаю, как далеко мне нужно искать, имеет смысл сделать итератор (о нём чуть дальше).

In [ ]:
isinner(idx, m) = 1 < idx[1] < size(m, 1) && 1 < idx[2] < size(m, 2)

function findweakwall(room, seg, origin)
	segmentindices = filter(idx -> room[idx] == seg, CartesianIndices(room))
	maxdistance = maximum(idx -> taxicab(origin, idx), segmentindices)
	maxdistance += 2 # добавляем две клетки для удаления

	iter = NearestIterator(origin; maxdist=sum(size(room))) # см. далее
	iter = Iterators.takewhile(idx -> taxicab(origin, idx)  maxdistance, iter)
	iter = Iterators.filter(idx -> isinner(idx, room), iter)
	iter = Iterators.filter(idx -> iswall(idx, room), iter)
	nearest = collect(iter)
	# я мог бы делать дальше перебор по итератору через Iterators.accumulate в поисках самой слабой стены, 
	# но посчитал что проще собрать его в и сделать argmin
	argmin(idx -> distancetonearestsegment(room, idx; firstsegment=seg), nearest)
end
Out[0]:
findweakwall (generic function with 1 method)

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

In [ ]:
function distancetonearestsegment(room, origin; firstsegment)
	d1 = -1 # расстояние до стартового сегмента
	dother = -1 # расстояние до другого сегмента
	# тут по-хорошему нужно быть уверенным что в комнате хотя бы 2 сегмента, один из которых исходный
	for idx in NearestIterator(origin; skiporigin=true, maxdist=sum(size(room)))
		if !isinner(idx, room) continue end
		tile = room[idx]
		if iswall(tile) continue end
		if (tile == firstsegment && d1 == -1) d1 = taxicab(origin, idx) end
		if (tile != firstsegment && dother == -1) dother = taxicab(origin, idx) end
		if d1 != -1 && dother != -1 return d1+dother end
	end
end
Out[0]:
distancetonearestsegment (generic function with 1 method)

А теперь о самом итераторе. Этот объект можно использовать для перебора значений в цикле for (в функции distancetonearestsegment) или специальные функции для работы с итераторами (из модуля Iterators, см. функцию findweakwall). Чтобы сделать из итератора Vector, его необходимо собрать функцией collect.

Итератор - это любой объект, для которого определены методы iterate(iter::MyIterator) и iterate(iter::MyIterator, state). Первый начинает итерацию, а второй использует предыдущее состояние. Оба они возвращают либо кортеж (значение, состояние), либо nothing, чтобы закончить итерацию.

Поиск соседей осуществляется по-спирали. Когда соседи на расстоянии r заканчиваются, переходим на круг r+1 (по расстоянию городских кварталов)

In [ ]:
# создаю тип для итератора...
struct NearestIterator
	origin::CI{2} # исходная точка для поиска
	skiporigin::Bool # пропускать ли вывод исходный точки?
	maxdist::Int # максимальное расстояние от начальной точки (чисто для безопасности, чтобы не уйти в вечный цикл случайно)
end
# ... и дополнительный конструктор для него
NearestIterator(origin; skiporigin=true, maxdist=100) = NearestIterator(origin, skiporigin, maxdist)

# начало итерации. Если включаем начальную точку, то начинаем с неё, если нет, то с первого соседа.
function Base.iterate(iter::NearestIterator)
	iter.skiporigin ? (iter.origin + CI(-1,0), CI(-1,0)) : (iter.origin, CI(0,0))
end

function Base.iterate(iter::NearestIterator, state::CI{2})
	if state == CI(0,0)
		# если мы начали с исходной точки, продолжим с ближайшего соседа
		return (iter.origin + CI(-1, 0), CI(-1, 0))
	end
	dist = taxicab(iter.origin, iter.origin + state)

	if dist > iter.maxdist
		# окончание итерации, поскольку мы ушли слишком далеко
		return nothing
	end
	if state[2] == -1 && state[1] == -dist+1
		return (iter.origin + CI(-dist-1,0), CI(-dist-1, 0))
	end
	offset = if state[1] < 0 && state[2]  0
		CI(1,1)
	elseif state[2] > 0 && state[1]  0
		CI(1,-1)
	elseif state[1] > 0 && state[2]  0
		CI(-1,-1)
	else
		CI(-1,1)
	end
	return (iter.origin + state + offset, state + offset)
end

И это всё. Можем запустить нашу функцию, получить картинку и посчитать количество сегментов.

In [ ]:
let
	seg = floodfill(room)
	joinsegments!(seg)

    nsegments = filter(!iswall, seg)
    println("Количество найденных сегментов: $(length(unique(nsegments)))")

	# перекрашиваем сегмент обратно в неразмеченный для отрисовки тайлов
	seg[.!iswall.(seg)] .= 0
	drawsegments(seg)
end
Количество найденных сегментов: 1
Out[0]:
No description has been provided for this image

Как мы видим, все наши сегменты слились в один. Сделаем анимацию.

In [ ]:
let
    scale = 6
    encoder_options = (crf=23,)
    framerate=30
    path = joinpath(@__DIR__, "fill2.webm")

    initial = drawtiles(room; scale)
    initial = Matrix{RGB{N0f8}}(initial)

    open_video_out(path, initial, framerate=framerate, encoder_options=encoder_options) do writer

        cb(segments) = write(writer, Matrix{RGB{N0f8}}(drawsegments(segments; scale=scale)))

	    seg = floodfill(room; callback=cb)
		joinsegments!(seg; callback=cb)

		# перекрашиваем сегмент обратно в неразмеченный для отрисовки тайлов
		seg[.!iswall.(seg)] .= 0

		for _ in 1:10
			cb(seg)
		end
    end

    display_webm(path)
end

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