Search code examples
arraysswiftconsole-applicationascii-arttext-based

How can I generate a map displaying the layout of a house of a text-based game and print it out of the console?


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.


Solution

  • 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 |                
                    |               |                
                    +---------------+