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