Script Help - Procedural/Randomly Generated Dungeon

I’ve been looking all over for a half decent procedural generator that could be used to generate a dungeon from the stuff I’m building but to no avail. I had to resort to using a basic doors room generator kit but it’s not entirely what I was looking for as it was a very linear design, although it is easy to edit the rooms it provided and add new ones. I was originally trying to look for a script that can generate a dungeon-like structure which is non-linear, the structures are easy to edit and to implement more as well as being able to generate multiple floors/levels possibly without rooms clipping into each other. (Note: What I mean by “non-linear” is the dungeon can split down multiple paths and it s not just one straight path much like the doors kit I had to resort to using.)



Screenshot 2023-08-29 050202
Screenshot 2023-08-29 050133
Screenshot 2023-08-29 050003

What the kit included as well as a link to it.

At the very least I wanted to know how to make it non-linear/paths being able to split off in separate directions at the very least, but anything helps. If anyone knows of better kits for it or where to find a script or anything for that matter it helps none the less. (Note: I have like basically 0 scripting knowledge which is why I wrote this in the first place so please bear with me, lol.)

Thanks!

2 Likes

I’m also working on something very similar and I’m happy to share the code I’ve been working on.

The usage is like this

local DUNGEON_ORIGIN = Vector3.new(-400, 8, 200)
local DUNGEON_SIZE = Vector2.new(10, 10)
local dungeon = DungeonGenerator.new(TILE_SET)
dungeon:Generate(DUNGEON_ORIGIN, DUNGEON_SIZE)

The layout of the tile set model is quite flexible. Just have a parent model with an attribute “TileWidth” set to the size of the tiles/rooms. The tiles ultimately have to be square so that they match up with each other. Each tile also needs attributes setting to determine which “doors” it has to that the generator knows how to orient each tile.

Here is the actual library code. Note that it is still far from perfect, I’m currently working on adding extra constraints for things like a starter room and end boss room. It also doesn’t guarantee that all rooms are globally connected.


math.randomseed(tick())

local PI = math.pi
local HALF_PI = 0.5 * PI

local function rollList(list, num)
    -- Rolls a list such that
    -- rollList({"a", "b", "c", "d"}, 1) == {"b", "c", "d", "a"}

    local rolledList = {}
    for i = 1, #list do
        local newIdx = ((i + num - 1) % #list) + 1

        rolledList[i] = list[newIdx]
    end

    return rolledList
end

local Tile = {}
Tile.__index = Tile

function Tile.new(model: Model)
    local self = {}

    self.NorthDoor = model:GetAttribute("NorthDoor") == true
    self.EastDoor = model:GetAttribute("EastDoor") == true
    self.SouthDoor = model:GetAttribute("SouthDoor") == true
    self.WestDoor = model:GetAttribute("WestDoor") == true

    self.Model = model
    self.Rotation = model:GetPivot().Rotation

    setmetatable(self, Tile)
    return self
end

function Tile:Clone()
    return Tile.new(self.Model:Clone())
end

function Tile:SetPosition(position: Vector3)
    self.Model:PivotTo(CFrame.new(position) * self.Rotation)
end

type Tile = typeof(Tile.new())
type TileSet = {[string]: Tile}

local function createTileSet(tileSetModel: Model): TileSet
    local tileSet: TileSet = {}
    for _, child in tileSetModel:GetChildren() do

        local tileDoors = {
            child:GetAttribute("NorthDoor"),
            child:GetAttribute("EastDoor"),
            child:GetAttribute("SouthDoor"),
            child:GetAttribute("WestDoor")
        }

        for i = 0, 3 do

            local rotatedModel = child:Clone()

            rotatedModel:PivotTo(child:GetPivot() * CFrame.Angles(0, HALF_PI * i, 0))

            local newDoors = rollList(tileDoors, i)

            rotatedModel:SetAttribute("NorthDoor", newDoors[1])
            rotatedModel:SetAttribute("EastDoor", newDoors[2])
            rotatedModel:SetAttribute("SouthDoor", newDoors[3])
            rotatedModel:SetAttribute("WestDoor", newDoors[4])

            rotatedModel.Name = `{child.Name}_{i}`

            tileSet[rotatedModel.Name] = Tile.new(rotatedModel)
        end
    end
    return tileSet
end

type TileOption = {
    Position: {
        x: number,
        y: number
    },
    Tiles: {Tile}
}

type TileOptions = {TileOption}

local Dungeon = {}
Dungeon.__index = Dungeon

local EMPTY = " - "
local OUT_OF_BOUNDS = " x "

function Dungeon.new(size: Vector2, tileSet: TileSet, tileWidth: number)

    local self = {}

    local populatedTiles = {}
    for x = 1, size.X do
        local row = {}
        for y = 1, size.Y do
            row[y] = EMPTY
        end
        populatedTiles[x] = row
    end

    self.Size = size
    self.PopulatedTiles = populatedTiles
    self.TileSet = tileSet
    self.TileWidth = tileWidth

    setmetatable(self, Dungeon)
    return self
end

function Dungeon:Generate(origin: Vector3, parent: Instance)
    local dungeonModel = Instance.new("Model")
    dungeonModel.Name = "Dungeon"

    local tileOptions = self:_getTileOptions()

    while #tileOptions > 0 do

        table.sort(tileOptions, function(a: TileOption, b: TileOption)
            return #a.Tiles < #b.Tiles
        end)

        local smallestNumTiles = #tileOptions[1].Tiles
        local matchingOptionsCount = 0
        for i, options in tileOptions do
            if #options.Tiles > smallestNumTiles then
                break
            end
            matchingOptionsCount = i
        end

        local tileOption = tileOptions[math.random(1, matchingOptionsCount)]

        local position = tileOption.Position
        local tiles = tileOption.Tiles
        local tileIndex = math.random(1, #tiles)

        local tile: Tile = tiles[tileIndex]

        self.PopulatedTiles[position.X][position.Y] = tile:Clone()

        tileOptions = self:_getTileOptions()
    end

    for x, z, tile in self:GetTiles() do
        if tile == EMPTY then
            continue
        end
        local tilePosition = origin + self.TileWidth * (
            Vector3.xAxis * x +
            Vector3.zAxis * z
        )
        tile:SetPosition(tilePosition)
        tile.Model.Parent = dungeonModel
    end

    dungeonModel.Parent = parent
end

function Dungeon:_getTile(x, y)
    if x < 1 or x > self.Size.X or y < 1 or y > self.Size.Y then
        return OUT_OF_BOUNDS
    end
    return self.PopulatedTiles[x][y]
end

function Dungeon:_doesTileFit(tile: Tile, x: number, y: number): boolean

    local northTile = self:_getTile(x, y - 1)
    local eastTile = self:_getTile(x + 1, y)
    local southTile = self:_getTile(x, y + 1)
    local westTile = self:_getTile(x - 1, y)

    local northFits = (
        northTile == EMPTY or
        (tile.NorthDoor and (
            northTile ~= OUT_OF_BOUNDS and
            northTile.SouthDoor
        )) or (not tile.NorthDoor and (
            northTile == OUT_OF_BOUNDS or
            not northTile.SouthDoor
        ))
    )

    local eastFits = (
        eastTile == EMPTY or
        (tile.EastDoor and (
            eastTile ~= OUT_OF_BOUNDS and
            eastTile.WestDoor
        )) or (not tile.EastDoor and (
            eastTile == OUT_OF_BOUNDS or
            not eastTile.WestDoor
        ))
    )

    local southFits = (
        southTile == EMPTY or
        (tile.SouthDoor and (
            southTile ~= OUT_OF_BOUNDS and
            southTile.NorthDoor
        )) or (not tile.SouthDoor and (
            southTile == OUT_OF_BOUNDS or
            not southTile.NorthDoor
        ))
    )

    local westFits = (
        westTile == EMPTY or
        (tile.WestDoor and (
            westTile ~= OUT_OF_BOUNDS and
            westTile.EastDoor
        )) or (not tile.WestDoor and (
            westTile == OUT_OF_BOUNDS or
            not westTile.EastDoor
        ))
    )

    return northFits and eastFits and southFits and westFits
end

function Dungeon:_getTileOptions(): TileOptions
    local tileSet: TileSet = self.TileSet
    local tileOptions: TileOptions = {}

    for x, y, currentTile in self:GetTiles() do

        if currentTile ~= EMPTY then
            continue
        end

        local possibleTiles = {}

        for _, tile in tileSet do
            if self:_doesTileFit(tile, x, y) then
                table.insert(possibleTiles, tile)
            end
        end

        if #possibleTiles > 0 then
            table.insert(tileOptions, {
                Position = {
                    X = x,
                    Y = y
                },
                Tiles = possibleTiles
            })
        end

    end

    return tileOptions
end

function Dungeon:GetTiles()
    local thread = coroutine.create(function()
        for x, row in ipairs(self.PopulatedTiles) do
            for y, tile in ipairs(row) do
                coroutine.yield(x, y, tile)
            end
        end
    end)

    return function ()
        local _, x, y, tile = coroutine.resume(thread)
        return x, y, tile
    end
end

local DungeonGenerator = {}
DungeonGenerator.__index = DungeonGenerator

function DungeonGenerator.new(tileSetModel: Model)
    local self = {}

    local tileWidth = tileSetModel:GetAttribute("TileWidth")

    self.TileSet = createTileSet(tileSetModel)
    self.TileWidth = tileWidth

    setmetatable(self, DungeonGenerator)
    return self
end

function DungeonGenerator:Generate(origin: Vector3, size: Vector2)
    local dungeon = Dungeon.new(size, self.TileSet, self.TileWidth)
    dungeon:Generate(origin, workspace)
end

return DungeonGenerator
2 Likes