SF Rogue Room Connection

This article continues covering the challenges I faced while creating SF Rogue.

Challenge 3

Now that we have our random rooms generated, that don't overlap, we need to ensure that rooms that are next to each other are connected with doors.

We will continue using the same playground we created in the previous article, which can be found on GitHub.

One thing we know for sure if we are going to connect rooms, we will need a door to go through. We need to create a new Swift file in the Sources folder called Door...

import UIKit

struct Door {
    // A connection from a room to another, the connecting room will have an "opposite" door
    var connectingRoomNumber: Int   // The number of the room this door connects to
    var joiningPoint: CGPoint       // The position the door appears in the room
    var wall: Constants.DoorWall    // Which wall the door appears on
}

This struct uses a new enum to identify the wall the door appears on. Open the Constants file, add these 2 Constraints values...

static let doorSpace = CGFloat(Constraints.tileSize * 3) // Need to allow space for the door plus the 2 walls (if this is a corridor)
static let halfTileSize = Constraints.tileSize / 2       // Half the size for positional calculations

and add a new enum for the wall identification...

enum DoorWall {
    case right
    case left
    case top
    case bottom
}

Most of the work for finding the doors and creating them is handled in the Room class, add the following variable to the Room to keep a track of the doors the room has been allocated...

var doors = [Door]()    // Holds the doors the room has

Then create some additional computed properties, place them under the width property, to help with the matching of rooms...

var leftEdge: Int {
    return Int(self.node.frame.minX)
}

var rightEdge: Int {
    return Int(self.node.frame.maxX)
}

var bottomEdge: Int {
    return Int(self.node.frame.minY)
}

var topEdge: Int {
    return Int(self.node.frame.maxY)
}

In order for there to be a door the rooms must be "touching" on one of their sides. Its a bit more complicated than that, because not only do the sides have to be equal, they also need to be in the correct space. Take the following example...

Rooms 1, 3 and 5 all have a touching edges with rooms 2, 4 and 6, but we only want side doors between 1-2, 3-4 and 5-6. First we are going to make a helper function that will allow us to filter rooms that are touching on edges, this will reduce the processing we have to do...

public func sameEdge(toRoom: Room) -> Bool {
    if self.doors.filter( { $0.connectingRoomNumber == toRoom.number }).count > 0 {
        //we already have this connection and therefore we don't need to add another door
        return false
    }

    return self.leftEdge == toRoom.rightEdge ||
        self.rightEdge == toRoom.leftEdge ||
        self.topEdge == toRoom.bottomEdge ||
        self.bottomEdge == toRoom.topEdge
}

Now we can create a function, and a couple of further helper functions to do the fine tune comparison, which will determine if we can create an actual door...

public func buttingConnection(toRoom: Room) {
    if self.doors.filter( { $0.connectingRoomNumber == toRoom.number }).count > 0 {
        //we already have this connection
        return
    }

    if self.leftEdge == toRoom.rightEdge, sameVerticalSpace(toRoom) {
        createConnectingDoor(toRoom: toRoom, basedOnWall: .left)
        return
    } else if self.rightEdge == toRoom.leftEdge, sameVerticalSpace(toRoom) {
        createConnectingDoor(toRoom: toRoom, basedOnWall: .right)
        return
    } else if self.topEdge == toRoom.bottomEdge, sameHorizontalSpace(toRoom) {
        createConnectingDoor(toRoom: toRoom, basedOnWall: .top)
        return
    } else if self.bottomEdge == toRoom.topEdge, sameHorizontalSpace(toRoom) {
        createConnectingDoor(toRoom: toRoom, basedOnWall: .bottom)
        return
    }
}

func sameVerticalSpace(_ toRoom: Room) -> Bool {
    // check for complete cover first
    if self.bottomEdge <= toRoom.bottomEdge, self.topEdge >= toRoom.topEdge {
        return true
    }

    //Is there enough space for the door
    return self.frame.intersection(toRoom.frame).height >= Constants.Constraints.doorSpace
}

func sameHorizontalSpace(_ toRoom: Room) -> Bool {

    //check for complete cover first
    if self.leftEdge <= toRoom.leftEdge && self.rightEdge >= toRoom.rightEdge {
        return true
    }

    return self.frame.intersection(toRoom.frame).width >= Constants.Constraints.doorSpace
}    

This code will now highlight some errors as we've not written the function createConnectingDoor, it calculates positions for the doors in the room and the connecting room...

func createConnectingDoor(toRoom: Room, basedOnWall: Constants.DoorWall) {
    // Used to determine what edges we are comparing
    let sideDoor = (basedOnWall == .left || basedOnWall == .right)
    // Gives an intersection so that we know how much space we have for the door
    let intersection = self.frame.intersection(toRoom.frame)

    // Calculate the edges, adjusting by a tileSize to allow for the walls
    let maxEdge = Int(sideDoor ? intersection.maxY : intersection.maxX) - Constants.Constraints.tileSize
    let minEdge = Int(sideDoor ? intersection.minY : intersection.minX) + Constants.Constraints.tileSize

    // This gives a count of how many places the dooe could actually be placed
    let availablePositions = (maxEdge - minEdge) / Constants.Constraints.tileSize

    // Now pick a random position so that not all rooms appear in the same place
    let offset = Int.random(in: 0..<availablePositions)

    var myJoiningPoint = CGPoint.zero
    var toRoomJoiningPoint = CGPoint.zero

    // Calculate the tileSize offset of the door for both this room and the room we are connecting to
    let doorbaseOffset = (minEdge - (sideDoor ? self.bottomEdge : self.leftEdge)) / Constants.Constraints.tileSize
    let toRoomOffset = (minEdge - (sideDoor ? toRoom.bottomEdge : toRoom.leftEdge)) / Constants.Constraints.tileSize

    var oppositeWall: Constants.DoorWall = .right
    // Calculate the X/Y points for the door in each room.
    if sideDoor {
        myJoiningPoint.y = CGFloat(doorbaseOffset + offset)
        myJoiningPoint.x = (basedOnWall == .left) ? 0 : CGFloat(cols - 1)
        toRoomJoiningPoint.y = CGFloat(toRoomOffset + offset)
        toRoomJoiningPoint.x = (basedOnWall == .left) ? CGFloat(toRoom.cols - 1) : 0
        oppositeWall = (basedOnWall == .left) ? .right : .left
    } else {
        myJoiningPoint.x = CGFloat(doorbaseOffset + offset)
        myJoiningPoint.y = (basedOnWall == .bottom) ? 0 : CGFloat(rows-1)
        toRoomJoiningPoint.x = CGFloat(toRoomOffset + offset)
        toRoomJoiningPoint.y = (basedOnWall == .bottom) ? CGFloat(toRoom.rows-1 ) : 0
        oppositeWall = (basedOnWall == .bottom) ? .top : .bottom
    }

    // Create a door for this room and for the room its connecting to
    self.doors.append(Door(connectingRoomNumber: toRoom.number,
                            joiningPoint: myJoiningPoint,
                            wall: basedOnWall))
    toRoom.doors.append(Door(connectingRoomNumber: self.number,
                                joiningPoint: toRoomJoiningPoint,
                                wall: oppositeWall))
}

Thats all the code in place to work out where doors should be within the rooms, we now need to update the Playground to call them. Open the Playground file and add the following function after arrangeRooms...

func findConnections() {
    // Loop through the rooms, except the last one as there are no rooms further
    // down the array and we would of processed any connections already
    for count in 0..<rooms.count-1 {
        let thisRoom = rooms[count]
        // Looking at rooms further in the array, find ones that have touching edges
        let touchingRooms = Array(rooms[count+1..<rooms.count])
            .filter( { thisRoom.sameEdge(toRoom: $0)})

        for touchingRoom in touchingRooms {
            // Now for the ones with touching edges, see if we can actually make a connection
            thisRoom.buttingConnection(toRoom: touchingRoom)
        }
    }
}

We are close to being able to run, all we have to do is call this method and actually render the doors. Change the didMove code to the following...

override func didMove(to view: SKView) {
    // Now we have a view to render too, create how many rooms we need for the map
    for number in 0..<totalRooms {
        let room = Room(number: number)
        self.rooms.append(room)
    }

    var arrangedCount = 0
    repeat {
        arrangedCount = arrangeRooms()
    } while arrangedCount > 0

    findConnections()

    for room in rooms {
        room.render(scene: self)
    }
}

We've added the findConnections call and moved the rendering of the rooms to the end. Now we just need to draw the doors, in the Room render function...

public func render(scene: SKScene) {
    drawDoors()
    scene.addChild(self.node)
}

func drawDoors() {
    for door in self.doors {

        // Create a square that represents the door
        let doorShape =  SKShapeNode(rectOf: CGSize(width: Constants.Constraints.tileSize, height: Constants.Constraints.tileSize))
        doorShape.lineWidth = 0
        doorShape.fillColor = .blue

        // Now calculate the X/Y position 
        let x = (Int(door.joiningPoint.x) * Constants.Constraints.tileSize) - (Int(width) / 2) + Constants.Constraints.halfTileSize
        let y = (Int(door.joiningPoint.y) * Constants.Constraints.tileSize) - (Int(height) / 2) + Constants.Constraints.halfTileSize

        doorShape.position = CGPoint(x: x, y: y)

        self.node.addChild(doorShape)
    }
}

The Playground is now ready to run, and should produce something like this in the LiveView...

Challenge 3 Complete!

Congratulations, we now have a number of random sized rooms, that don't overlap, with connecting doors, being rendered in SpriteKit. You'll notice from the above example that some of the rooms don't have any doors and there are also a group of rooms on the right that are "floating", its time to move onto corridors.

The Playground for this article is available on GitHub

If you have any questions or comments you can reach me on Twitter.