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