0

I want to design a text based game that has a house as a layout with Swift (coded in a Linux VM on Visual Studio Code). To achieve the house layout I have different 'Room' objects that have exits in each cardinal direction (North, East, South, West). When specifying which room has which exits, I also assign it the room the exit leads to with a dictionary structure.

enum Direction:String {
    /// 4 main cardinal points
    case North, East, South, West
}

/// A rudimentary implementation of a room (location) within the game map, based on a name and exits to other rooms. The exits are labeled with a `Direction`.
class Room {
    
    /// The name of the room (does not need to be an identifier)
    var name:String
    
    /// The exit map (initially empty)
    var exits = [Direction:Room]()
    
    /// Initializer
    init(name:String) {
        self.name = name
    }

    /**
     This function allows to retrieve a neighboring room based on its exit direction from inside the current room.
     
     - Parameters:
        - direction: A `Direction`
     - Returns: The room through the exit in the next direction (if any)
     */
    func nextRoom(direction:Direction) -> Room? {
        return exits[direction]
    }
    
}

/// Extension for the `CustomStringConvertible` protocol.
extension Room:CustomStringConvertible {

    /// The description includes the name of the room as well as all possible exit directions
    var description: String {
        return "You are in \(self.name)\n\nExits:\n\(self.exits.keys.map { "- \($0.rawValue)" }.joined(separator: "\n"))"
    }
    
}

I currently have designed a layout of rooms that looks like the following:

Diagram of the house's room layout

I already coded a textual representation of each room. The plan is to add different houses in the long run to introduce more levels, so I don't want to hardcode the map if possible.

The problem I am trying to solve for quite some time now is printing out the correct positioning of the rooms. I think I could solve the issue by writing an algorithm that parses through the rooms one by one and positions them accordingly in a 2D array.

Does anyone have any tips of solutions that could help me advance my code in some way?

I very much appreciate any feedback given on my problem.

  • I tried splitting the rooms horizontally and parsing through the first row of rooms one by one starting from the left most room and advancing to the right most room. I would then assign each room a x and y coordinate and continue to the next room. Once I reached the right most room, I would then proceed to the left most room one row beneath and repeat the process. I would do that with each row, but I noticed that printing out that version of the map will position the rooms in the wrong position (e.g. kitchen would be aligned with bathroom, etc. )
  • I also tried expanding the house's layout with "empty rooms" that I put on the outside edges of the house thinking that this way, the positioning while parsing through would be different than before. It wasn't.

I wasn't able to test much more since I didn't have many more ideas on how to fix this and searching the web didn't help much either since most results were linked to XCode and example code from other text-based games all had hard coded maps integrated.

  • In your diagram what exactly does the red line between "Main Entrance" and "Hallway" mean? – Chip Jarred Apr 30 '23 at 02:45
  • The red line is there to show where the main entrance is. The room “main entrance” is actually the room where the player can type in “go north” to get into the house. Once the player is inside the house he cannot get out of the house anymore until he bears the game – StryfingBl4ck Apr 30 '23 at 11:19

1 Answers1

0

In this answer I don't handle the one-way "door" from the main entrance to the hallway (as described in comments, when I asked what the red line meant).

I assume that rooms are all the same size, and can be viewed as occupying a single cell in a grid, and that exits are bidirectional, that is if there is an exit in A to B, there is also an exit in B to A, which apparently from comments, doesn't apply for the main entrance, but for printing a map, I think these assumptions will work. I also assume that if A has an exit to B then A is physically adjacent to B (ie, no teleports!).

Let's start by defining a type to represent a room location:

// ---------------------------------
struct RoomLocation: Hashable, Equatable
{
    static let zero = RoomLocation(x: 0, y: 0)
    
    let x: Int
    let y: Int
    
    // ---------------------------------
    func translate(deltaX: Int = 0, deltaY: Int = 0) -> RoomLocation {
        return Self(x: x + deltaX, y: y + deltaY)
    }
    
    // ---------------------------------
    func scale(by factor: Int) -> RoomLocation {
        return Self(x: x * factor, y: y * factor)
    }
}

I also found it helpful to make Room conform to Identifiable, so let's get that out of the way:

// ---------------------------------
extension Room: Identifiable {
    var id: Int { name.hashValue }
}

I also assume that the positive x-axis points east, and that the positive y-axis points south.

Given those assumptions, we can work out the positions of the rooms, given a starting room. From a Sequence of rooms, I just use the first one as the reference, so I say that it's at (0, 0) I use that room's id to add it to a dictionary that maps from the id to the room location.

For each room remaining in the Sequence, I go through that room's exits looking for an adjacent room that we've already got a position for in our dictionary. If I find such an adjacent room, then we can determine our current room's position from it: If we looked north for the adjacent room, then our current room's y is the adjacent y + 1, because the current room is to the south of the adjacent room. Similarly if we look south, then we use the adjacent room's y - 1. If we look east, then use the adjacent room's x - 1, and if we look west, use its x + 1. If the current room doesn't have any adjacent rooms with known positions, then I put it in an "unaddedRooms" array to be tried again in another iteration after more rooms have been added. Along the way I'm keeping track of the maximum and minimum x an y values.

Once I have a mapping for all the room ID's to positions, I offset all their positions by the negative of minimum x and y in order to put the west-most room at x = 0, and the north-most room at y = 0, and I store all offset positions in a dictionary that maps locations to rooms. I call the result a Layout. Here's what it looks like:

// ---------------------------------
struct Layout
{
    var roomsByLocation: [RoomLocation: Room] = [:]
    
    private var maxX = Int.min
    private var maxY = Int.min
    private var minX = Int.max
    private var minY = Int.max
    
    // ---------------------------------
    subscript(x: Int, y: Int) -> Room? {
        return roomsByLocation[.init(x: x, y: y)]
    }
    
    // ---------------------------------
    init<RoomSequence: Sequence>(_ rooms: RoomSequence)
        where RoomSequence.Element == Room
    {
        // First we calculate coordinates relative to a seed room
        var locationsByRoom = [Room.ID: (Room, RoomLocation)]()
        var unaddedRooms = [Room]()
        
        var roomsAdded = addSeedRoom(
            from: rooms,
            to: &locationsByRoom,
            at: .zero,
            unaddedRooms: &unaddedRooms
        )
        
        var newUnaddedRooms = [Room]()
        newUnaddedRooms.reserveCapacity(unaddedRooms.count)
        
        while roomsAdded > 0
        {
            var roomIter = unaddedRooms.makeIterator()
            roomsAdded = addRooms(
                from: &roomIter,
                to: &locationsByRoom,
                unaddedRooms: &newUnaddedRooms
            )
            
            swap(&unaddedRooms, &newUnaddedRooms)
            newUnaddedRooms.removeAll(keepingCapacity: true)
        }
        
        /*
         If we still have unadded rooms, it means that there are rooms that are
         unreachable from the seed room.  That's probably a error in the level
         design, but we need to add them.  We create separate independent
         layout for those unconnected rooms, and append that below (ie, to the
         south) of our connected rooms.
         */
        if unaddedRooms.count > 0
        {
            print("Warning: There are rooms that are unreachable from other rooms")
            let unconnectedLayout = Layout(unaddedRooms)
            
            let localDeltaX = 0
            let localDeltaY = maxY + 1
            
            for (unconnectedLoc, unconnectedRoom) in unconnectedLayout
            {
                let newLoc = unconnectedLoc
                    .translate(deltaX: localDeltaX, deltaY: localDeltaY)
                locationsByRoom[unconnectedRoom.id] = (unconnectedRoom, newLoc)
                updateMinMax(newLoc)
            }
        }

        /*
         Now all of the rooms have relative coordinates assigned to them, but
         now we want to offset them so that the most north-westerly room is
         at the origin.
         */
        for (room, loc) in locationsByRoom.values
        {
            let newLoc = loc.translate(deltaX: -minX, deltaY: -minY)
            assert(self.roomsByLocation[newLoc] == nil)
            self.roomsByLocation[newLoc] = room
        }
    }
    
    // ---------------------------------
    mutating func addSeedRoom<RoomSequence: Sequence>(
        from rooms: RoomSequence,
        to locationsByRoom: inout [Room.ID: (Room, RoomLocation)],
        at location: RoomLocation,
        unaddedRooms: inout [Room]) -> Int
        where RoomSequence.Element == Room
    {
        var iter = rooms.makeIterator()
        guard let firstRoom = iter.next() else {
            return 0
        }
        
        locationsByRoom[firstRoom.id] = (firstRoom, location)
        updateMinMax(location)
        
        let roomsAdded = addRooms(
                from: &iter,
                to: &locationsByRoom,
                unaddedRooms: &unaddedRooms
        )
        
        return roomsAdded + 1
    }
    
    // ---------------------------------
    mutating func addRooms<RoomIterator: IteratorProtocol>(
        from roomIter: inout RoomIterator,
        to locationsByRoom: inout [Room.ID: (Room, RoomLocation)],
        unaddedRooms: inout [Room]) -> Int
        where RoomIterator.Element == Room
    {
        var roomsAdded = 0
        while let room = roomIter.next()
        {
            var location: RoomLocation
            if let adjacentRoom = room.exits[.North],
               let adjacentLoc = locationsByRoom[adjacentRoom.id]?.1
            {
                location = adjacentLoc.translate(deltaY: +1)
            }
            else if let adjacentRoom = room.exits[.South],
                    let adjacentLoc = locationsByRoom[adjacentRoom.id]?.1
            {
                location = adjacentLoc.translate(deltaY: -1)
            }
            else if let adjacentRoom = room.exits[.East],
                    let adjacentLoc = locationsByRoom[adjacentRoom.id]?.1
            {
                location = adjacentLoc.translate(deltaX: -1)
            }
            else if let adjacentRoom = room.exits[.West],
                    let adjacentLoc = locationsByRoom[adjacentRoom.id]?.1
            {
                location = adjacentLoc.translate(deltaX: +1)
            }
            else
            {
                unaddedRooms.append(room)
                continue
            }
            
            locationsByRoom[room.id] = (room, location)
            updateMinMax(location)
            roomsAdded += 1
        }
        
        return roomsAdded
    }
    
    // ---------------------------------
    mutating func updateMinMax(_ location: RoomLocation)
    {
        maxX = Swift.max(location.x, maxX)
        maxY = Swift.max(location.y, maxY)
        minX = Swift.min(location.x, minX)
        minY = Swift.min(location.y, minY)
    }
}

extension Layout: Sequence
{
    typealias Iterator = Dictionary<RoomLocation, Room>.Iterator
    typealias Element = Iterator.Element

    func makeIterator() -> Dictionary<RoomLocation, Room>.Iterator {
        roomsByLocation.makeIterator()
    }
}

In Layout I try to handle the case of unconnected groups of rooms in some sensible way. Basically I just relocate them to the bottom of the layout. My sense is that such unreachable rooms are probably errors in building the rooms and assigning exits.

Now that I have the rooms with actual locations assigned to them, I go one more step to create a Map. The Map is sort of an expanded version of the Layout. Whereas the Layout just has rooms in it, the Map contains elements such as walls and doors that need to be drawn to show how rooms are connected, and it can produce a String to print as an ASCII-art map. It looks like this:

// ---------------------------------
struct Map
{
    // ---------------------------------
    enum CellType
    {
        case empty
        case room(_ room: Room)
        case northSouthDoor
        case eastWestDoor
        case northSouthWall
        case eastWestWall
        case wallCorner
    }
    
    var map: [RoomLocation: CellType] = [:]
    
    let maxNameWidth: Int
    
    let width : Int
    let height: Int
    
    // ---------------------------------
    init(from layout: Layout)
    {
        var maxNameWidth = 0
        var mapWidth     = 0
        var mapHeight    = 0
        
        
        for (_, room) in layout {
            maxNameWidth = max(maxNameWidth, room.name.count)
        }
        
        for (roomLoc, room) in layout
        {
            
            var mapLoc = roomLoc.scale(by: 2)
                        
            map[mapLoc]                      = .wallCorner
            map[mapLoc.translate(deltaX: 1)] = room.exits[.North] == nil
                ? .eastWestWall
                : .northSouthDoor
            map[mapLoc.translate(deltaX: 2)] = .wallCorner
            
            mapLoc = mapLoc.translate(deltaY: 1)
            map[mapLoc]                      = room.exits[.West] == nil
                ? .northSouthWall
                : .eastWestDoor
            map[mapLoc.translate(deltaX: 1)] = .room(room)
            map[mapLoc.translate(deltaX: 2)] = room.exits[.East] == nil
                ? .northSouthWall
                : .eastWestDoor
            
            mapLoc = mapLoc.translate(deltaY: 1)
            map[mapLoc]                      = .wallCorner
            map[mapLoc.translate(deltaX: 1)] = room.exits[.South] == nil
                ? .eastWestWall
                : .northSouthDoor
            map[mapLoc.translate(deltaX: 2)] = .wallCorner
            
            mapWidth  = max(mapWidth,  mapLoc.x + 2)
            mapHeight = max(mapHeight, mapLoc.y + 2)
        }
        
        self.maxNameWidth = maxNameWidth
        self.width        = mapWidth
        self.height       = mapHeight
    }
    
    // ---------------------------------
    func center(
        _ s: String,
        inWidth width: Int,
        padChar: Character = " ") -> String
    {
        precondition(s.count <= width)
        
        var result = ""
        result.reserveCapacity(width)
        
        let leadingSpaces  = (width - s.count) / 2
        let trailingSpaces = width - s.count - leadingSpaces
        
        result.append(contentsOf: repeatElement(padChar, count: leadingSpaces))
        result.append(s)
        result.append(contentsOf: repeatElement(padChar, count: trailingSpaces))
        
        return result
    }
    
    // ---------------------------------
    fileprivate func roomLineStr(
        _ y: Int,
        _ spaces: String,
        namedCellWidth: Int) -> String
    {
        assert(y & 1 == 1)
        
        var result: String = ""
        for x in 0...width
        {
            let mapLoc = RoomLocation(x: x, y: y)
            switch map[mapLoc] ?? .empty
            {
                case .empty:
                    // even x coords are on room boundaries
                    result.append(mapLoc.x & 1 == 1 ? spaces : " ")
                    
                case .eastWestDoor:
                    assert(mapLoc.x & 1 == 0)
                    result.append(" ")
                    
                case .northSouthDoor:
                    assertionFailure()
                    break
                    
                case .room(let room):
                    assert(mapLoc.x & 1 == 1)
                    let cellStr = center(room.name, inWidth: namedCellWidth)
                    result.append(cellStr)
                    
                case .wallCorner:
                    assertionFailure()
                    break
                    
                case .eastWestWall:
                    assertionFailure()
                    break
                    
                case .northSouthWall:
                    assert(mapLoc.x & 1 == 0)
                    result.append("|")
            }
        }
        
        result.append("\n")
        return result
    }
    
    // ---------------------------------
    fileprivate func marginLineStr(_ y: Int, _ spaces: String) -> String
    {
        assert(y & 1 == 1)
        
        var result: String = ""
        for x in 0...width
        {
            let mapLoc = RoomLocation(x: x, y: y)
            switch map[mapLoc] ?? .empty
            {
                case .empty:
                    // even x coords are on room boundaries
                    result.append(mapLoc.x & 1 == 1 ? spaces : " ")
                    
                case .northSouthDoor:
                    assertionFailure()
                    break
                    
                case .room(_):
                    assert(mapLoc.x & 1 == 1)
                    result.append(spaces)
                    
                case .wallCorner:
                    assertionFailure()
                    break
                    
                case .eastWestWall:
                    assertionFailure()
                    break
                    
                case .eastWestDoor, .northSouthWall:
                    assert(mapLoc.x & 1 == 0)
                    result.append("|")
            }
        }
        
        result.append("\n")
        return result
    }

    // ---------------------------------
    fileprivate func boundaryLineStr(
        _ y: Int,
        _ spaces: String,
        nsDoorStr: String,
        ewWallStr: String,
        namedCellWidth: Int) -> String
    {
        assert(y & 1 == 0)
        
        var result: String = ""
        for x in 0...width
        {
            let mapLoc = RoomLocation(x: x, y: y)
            switch map[mapLoc] ?? .empty
            {
                case .empty:
                    // cells on even x coordinates are boundaries between rooms
                    result.append(mapLoc.x & 1 == 1 ? spaces : " ")
                    
                case .northSouthDoor:
                    result.append(nsDoorStr)
                    
                case .room(_):
                    assertionFailure()
                    break
                    
                case .wallCorner:
                    assert(mapLoc.x & 1 == 0)
                    result.append("+")
                    
                case .eastWestWall:
                    assert(mapLoc.x & 1 == 1)
                    result.append(ewWallStr)
                    
                case .eastWestDoor, .northSouthWall:
                    assert(mapLoc.x & 1 == 0)
                    result.append("|")
            }
        }
        
        result.append("\n")
        return result
    }

    // ---------------------------------
    var string: String
    {
        var result: String = ""

        let hMarginWidth  = 1
        let vMarginHeight = 1
        
        let namedCellWidth = 2 * hMarginWidth + maxNameWidth
        let spaces = String(repeating: " ", count: namedCellWidth)
        
        let nsDoorStr = center("  ", inWidth: namedCellWidth, padChar: "-")
        let ewWallStr = center("", inWidth: namedCellWidth, padChar: "-")
        
        for y in 0..<height
        {
            if y & 1 == 0
            {
                result += boundaryLineStr(
                    y,
                    spaces,
                    nsDoorStr: nsDoorStr,
                    ewWallStr: ewWallStr,
                    namedCellWidth: namedCellWidth
                )
                
                continue
            }
            
            
            for _ in 0..<vMarginHeight {
                result += marginLineStr(y, spaces)
            }
            
            result += roomLineStr(
                y,
                spaces,
                namedCellWidth: namedCellWidth
            )
            
            for _ in 0..<vMarginHeight {
                result += marginLineStr(y, spaces)
            }
        }
        
        return result
    }
}

To test it, I programmatically define the rooms, connect them and put them in a list, duplicating rooms shown in the image you linked, except for the one clipped on the right of that image (which I think was supposed to be "Basement" - in any case, I didn't include it).

Here's what that "test" code looks like:

func testGameMap()
{
    func connect(from src: Room, to dst: Room, _ direction: Direction)
    {
        src.exits[direction] = dst
        
        let reverseDirection: Direction
        switch direction
        {
            case .North: reverseDirection = .South
            case .South: reverseDirection = .North
            case .East : reverseDirection = .West
            case .West : reverseDirection = .East
        }
        
        dst.exits[reverseDirection] = src
    }
    
    let mainEntrance = Room(name: "Main Entrance")
    let bathroom     = Room(name: "Bathroom")
    let hallway      = Room(name: "Hallway")
    let livingRoom   = Room(name: "Living Room")
    let kitchen      = Room(name: "Kitchen")
    let diningRoom   = Room(name: "Dining Room")
    
    connect(from: mainEntrance, to: hallway, .North)
    connect(from: bathroom, to: hallway, .East)
    connect(from: hallway, to: livingRoom, .East)
    connect(from: hallway, to: kitchen, .North)
    connect(from: kitchen, to: diningRoom, .East)
    connect(from: livingRoom, to: diningRoom, .North)
    
    var rooms = [Room]()
    rooms.append(mainEntrance)
    rooms.append(bathroom)
    rooms.append(hallway)
    rooms.append(kitchen)
    rooms.append(diningRoom)
    rooms.append(livingRoom)

    let layout = Layout(rooms)
    let roomMap = Map(from: layout)
    
    print(roomMap.string)
}

And here's the output

                +---------------+---------------+
                |               |               |
                |    Kitchen       Dining Room  |
                |               |               |
+---------------+------  -------+------  -------+
|               |               |               |
|   Bathroom         Hallway       Living Room  |
|               |               |               |
+---------------+------  -------+---------------+
                |               |                
                | Main Entrance |                
                |               |                
                +---------------+                
                                                 
Chip Jarred
  • 2,600
  • 7
  • 12
  • Thank you very much for your answer! After implementing your solution my map works perfectly and I’m amazed by your coding structure. It was very easy to understand! – StryfingBl4ck May 01 '23 at 12:40
  • @StryfingBl4ck, you're welcome, and thank you for the compliments. I'm glad may answer was helpful. – Chip Jarred May 01 '23 at 16:20