UNPKG

dungeoneer

Version:
585 lines (493 loc) 15.3 kB
/** * Based on Bob Nystrom's procedural dungeon generation logic that he wrote for Hauberk * http://journal.stuffwithstuff.com/2014/12/21/rooms-and-mazes/ */ 'use strict' const Chance = require('chance') const Victor = require('victor') const _ = require('underscore') const Room = require('./room') const Tile = require('./tile') const getTileNESW = (tile) => { const tiles = [] if (tile.neighbours.n) { tiles.push(tile.neighbours.n) } if (tile.neighbours.e) { tiles.push(tile.neighbours.e) } if (tile.neighbours.s) { tiles.push(tile.neighbours.s) } if (tile.neighbours.w) { tiles.push(tile.neighbours.w) } return tiles } const nameChance = new Chance() /** * @desc The random dungeon generator. * * Starting with a stage of solid walls, it works like so: * * 1. Place a number of randomly sized and positioned rooms. If a room * overlaps an existing room, it is discarded. Any remaining rooms are * carved out. * 2. Any remaining solid areas are filled in with mazes. The maze generator * will grow and fill in even odd-shaped areas, but will not touch any * rooms. * 3. The result of the previous two steps is a series of unconnected rooms * and mazes. We walk the stage and find every tile that can be a * "connector". This is a solid tile that is adjacent to two unconnected * regions. * 4. We randomly choose connectors and open them or place a door there until * all of the unconnected regions have been joined. There is also a slight * chance to carve a connector between two already-joined regions, so that * the dungeon isn't single connected. * 5. The mazes will have a lot of dead ends. Finally, we remove those by * repeatedly filling in any open tile that's closed on three sides. When * this is done, every corridor in a maze actually leads somewhere. * * The end result of this is a multiply-connected dungeon with rooms and lots * of winding corridors. * * @constructor */ const Dungeon = function Dungeon () { var numRoomTries = 50 // The inverse chance of adding a connector between two regions that have // already been joined. Increasing this leads to more loosely connected // dungeons. var extraConnectorChance = 50 // Increasing this allows rooms to be larger. var roomExtraSize = 0 var windingPercent = 50 var _rooms = [] // The index of the current region being carved. var _currentRegion = -1 var stage var rng const n = new Victor(0, 1) const e = new Victor(1, 0) const s = new Victor(0, -1) const w = new Victor(-1, 0) // The four cardinal directions: north, south, east, and west. const cardinalDirections = [n, e, s, w] const bindStage = (givenStage) => { stage = givenStage } let _tiles = [] let _seed const randBetween = (min, max) => { return rng.integer({ min, max }) } /** * @desc returns a tile at the provided coordinates * * @param {Number} x - The x coordinate to retrieve * @param {Number} y - The y coordinate to retrieve * * @returns {Object} - A Tile object */ const getTile = (x, y) => { return _tiles[x][y] } /** * @desc Sets a tile's type and region. This function will thrown an error if * the tile doesn't exist. * * @param {Number} x - The x coordinate of the tile to set * @param {Number} y - The y coordinate of the tile to set * @param {String} type - The type to set on the tile * * @returns {Object} - The Tile object or null if the tile was not found * */ const setTile = (x, y, type) => { if (_tiles[x] && _tiles[x][y]) { _tiles[x][y].type = type _tiles[x][y].region = _currentRegion return _tiles[x][y] } throw new RangeError(`tile at ${x}, ${y} is unreachable`) } /** * @desc Generates tile data to the dimension of the stage. * * @param {String} type - The tile type to set on newly created tiles * * @returns {Array} - The _tiles array */ const fill = (type) => { let neighbours = {} var x var y for (x = 0; x < stage.width; x++) { _tiles.push([]) for (y = 0; y < stage.height; y++) { _tiles[x].push(new Tile(type, x, y)) } } for (x = 0; x < stage.width; x++) { for (y = 0; y < stage.height; y++) { neighbours = {} if (_tiles[x][y - 1]) { neighbours.n = _tiles[x][y - 1] } if (_tiles[x + 1] && _tiles[x + 1][y - 1]) { neighbours.ne = _tiles[x + 1][y - 1] } if (_tiles[x + 1] && _tiles[x + 1][y]) { neighbours.e = _tiles[x + 1][y] } if (_tiles[x + 1] && _tiles[x + 1][y + 1]) { neighbours.se = _tiles[x + 1][y + 1] } if (_tiles[x] && _tiles[x][y + 1]) { neighbours.s = _tiles[x][y + 1] } if (_tiles[x - 1] && _tiles[x - 1][y + 1]) { neighbours.sw = _tiles[x - 1][y + 1] } if (_tiles[x - 1] && _tiles[x - 1][y]) { neighbours.w = _tiles[x - 1][y] } if (_tiles[x - 1] && _tiles[x - 1][y - 1]) { neighbours.nw = _tiles[x - 1][y - 1] } _tiles[x][y].setNeighbours(neighbours) } } return _tiles } /** * @desc Master function for generating a dungeon * * @param {Object} stage - An object with a width key and a height key. Used * to determine the size of the dungeon. Must be odd with and height. * * @returns {Object} - Tile information for the dungeon */ const build = (stage) => { if (stage.width < 5) { throw new RangeError(`DungeoneerError: options.width must not be less than 5, received ${stage.width}`) } if (stage.height < 5) { throw new RangeError(`DungeoneerError: options.height must not be less than 5, received ${stage.height}`) } if (stage.width % 2 === 0) { stage.width += 1 } if (stage.height % 2 === 0) { stage.height += 1 } const seed = stage.seed || `${nameChance.word({ length: 7 })}-${nameChance.word({ length: 7 })}` rng = new Chance(seed) _seed = seed bindStage(stage) fill('wall') _addRooms() // Fill in all of the empty space with mazes. for (var y = 1; y < stage.height; y += 2) { for (var x = 1; x < stage.width; x += 2) { // Skip the maze generation if the tile is already carved if (getTile(x, y).type === 'floor') { continue } _growMaze(x, y) } } _connectRegions() _removeDeadEnds() return { rooms: _rooms, tiles: _tiles, seed, toJS: _toJS } } const _toJS = () => { const rooms = [] const tiles = [] for (const room of _rooms) { rooms.push(room.toJS()) } for (let x = 0; x < _tiles.length; x++) { if (!tiles[x]) { tiles.push([]) } for (let y = 0; y < _tiles[x].length; y++) { const tile = _tiles[x][y] tiles[x].push(tile.toJS()) } } return { tiles, rooms, seed: _seed } } /** * @desc Implementation of the "growing tree" algorithm from here: * http://www.astrolog.org/labyrnth/algrithm.htm. * * @param {Number} startX - The x coordinate to start at * @param {Number} startY - The y coordinate to start at * * @returns {void} */ const _growMaze = (startX, startY) => { var cells = [] var lastDir if (Object.keys(_tiles[startX][startY].neighbours).filter(x => x.type === 'floor').length > 0) { return } _startRegion() _carve(startX, startY) cells.push(new Victor(startX, startY)) let count = 0 while (cells.length && count < 500) { count++ var cell = cells[cells.length - 1] // See which adjacent cells are open. var unmadeCells = [] for (let dir of cardinalDirections) { if (_canCarve(cell, dir)) { unmadeCells.push(dir) } } if (unmadeCells.length) { // Based on how "windy" passages are, try to prefer carving in the // same direction. var dir var stringifiedCells = unmadeCells.map(v => v.toString()) if (lastDir && stringifiedCells.indexOf(lastDir.toString()) > -1 && randBetween(1, 100) > windingPercent) { dir = lastDir.clone() } else { let rand = randBetween(0, unmadeCells.length - 1) dir = unmadeCells[rand].clone() } let carveLoc1 = cell.clone().add(dir).toObject() _carve(carveLoc1.x, carveLoc1.y) let carveLoc2 = cell.clone().add(dir).add(dir).toObject() _carve(carveLoc2.x, carveLoc2.y) cells.push(cell.clone().add(dir).add(dir)) lastDir = dir.clone() } else { // No adjacent uncarved cells. cells.pop() // This path has ended. lastDir = null } } } /** * @desc Creates rooms in the dungeon by repeatedly creating random rooms and * seeing if they overlap. Rooms that overlap are discarded. This process is * repeated until it hits the maximum tries determined by the 'numRoomTries' * variable. * * @returns {void} */ const _addRooms = () => { for (var i = 0; i < numRoomTries; i++) { // Pick a random room size. The funny math here does two things: // - It makes sure rooms are odd-sized to line up with maze. // - It avoids creating rooms that are too rectangular: too tall and // narrow or too wide and flat. var size = randBetween(1, 3 + roomExtraSize) * 2 + 1 var rectangularity = randBetween(0, 1 + Math.floor(size / 2)) * 2 var width = size var height = size if (_oneIn(2)) { width += rectangularity } else { height += rectangularity } // Restrict the size of rooms relative to the stage size width = Math.min(width, stage.width - 4) height = Math.min(width, stage.height - 4) var x = randBetween(0, Math.floor((stage.width - width) / 2)) * 2 + 1 var y = randBetween(0, Math.floor((stage.height - height) / 2)) * 2 + 1 // Make sure X dimension doesn't overflow if (x + width > stage.width) { x = Math.max(1, stage.width - width - 1) } // Make sure Y dimension doesn't overflow if (y + height > stage.height) { y = Math.max(1, stage.height - height - 1) } var room = new Room(x, y, width, height) var overlaps = false for (var other of _rooms) { if (room.intersects(other)) { overlaps = true break } } if (overlaps) { continue } _rooms.push(room) _startRegion() // Convert room tiles to floor carveArea(x, y, width, height) } } /** * @desc converts an area of tiles to floor type * * @param {Number} x - The starting x coordinate * @param {Number} y - The starting y coordinate * @param {Number} width - The width of the area to carve * @param {Number} height - The height of the area to carve * * @returns {void} */ const carveArea = (x, y, width, height) => { for (var i = x; i < x + width; i++) { for (var j = y; j < y + height; j++) { _carve(i, j) } } } /** * @desc Creates doorways between each generated region of tiles * * @return {void} */ const _connectRegions = () => { let regionConnections = {} _tiles.forEach(row => { row.forEach(tile => { if (tile.type === 'floor') { return } let tileRegions = _.unique( getTileNESW(tile).map(x => x.region) .filter(x => !_.isUndefined(x)) ) if (tileRegions.length <= 1) { return } let key = tileRegions.join('-') if (!regionConnections[key]) { regionConnections[key] = [] } regionConnections[key].push(tile) }) }) _.each(regionConnections, (connections) => { let index = randBetween(0, connections.length - 1) connections[index].type = 'door' connections.splice(index, 1) // Occasional open up additional connections connections.forEach(conn => { if (_oneIn(extraConnectorChance)) { conn.type = 'door' } }) }) } /** * @desc Helper function for calculating random chance. The higher the number * provided the less likely this value is to return true. * * @param {Number} num - The ceiling number that could be calculated * * @returns {Boolean} - True if the function rolled a one * * @example * _oneIn(50); // - Has a 1 in 50 chance of returning true */ const _oneIn = (num) => { return randBetween(1, num) === 1 } /** * @desc Fills in dead ends in the dungeon with wall tiles * * @returns {void} */ const _removeDeadEnds = () => { var done = false const cycle = () => { let done = true _tiles.forEach((row) => { row.forEach((tile) => { // If it only has one exit, it's a dead end --> fill it in! if (tile.type === 'wall') { return } if ( getTileNESW(tile).filter(t => t.type !== 'wall').length <= 1 && !_rooms.find((room) => room.containsTile(tile.x, tile.y)) ) { tile.type = 'wall' done = false } }) }) return done } while (!done) { done = true done = cycle() } } /** * @desc Gets whether or not an opening can be carved from the given starting * [Cell] at [pos] to the adjacent Cell facing [direction]. Returns `true` * if the starting Cell is in bounds and the destination Cell is filled * (or out of bounds).</returns> * * @param {Victor} cell - Victor JS vector object * @param {Victor} direction - Victor JS vector object indicating direction * * @return {Boolean} - true if the path can be carved */ const _canCarve = (cell, direction) => { // Must end in bounds. let end = cell.clone().add(direction).add(direction).add(direction).toObject() if (!_tiles[end.x] || !_tiles[end.x][end.y]) { return false } if (getTile(end.x, end.y).type !== 'wall') { return false } // Destination must not be open. let dest = cell.clone().add(direction).add(direction).toObject() return getTile(dest.x, dest.y).type !== 'floor' } /** * @desc Increments the current region. Typically called every time a new area * starts being carved * * @returns {Number} - The current region number */ const _startRegion = () => { _currentRegion++ return _currentRegion } /** * @desc Changes the Tile at a given coordinate to a provided type. Typically * used to change the type to 'floor' * * @param {Number} x - The x coordinate to change * @param {Number} y - The y coordinate to change * @param {String} type - The type to change the tile to. Defaults to 'floor' * * @returns {void} */ const _carve = (x, y, type = 'floor') => { setTile(x, y, type) } return { build } } const build = (options) => { return new Dungeon().build(options) } module.exports = { build }