Zork – Navigation

We have quite a bit of functionality in our game now, but its hard to show it off without being able to move. In this lesson we will finally allow you to explore the world by navigating from one room to another. Along the way we will also update our systems so you can see the descriptions of the rooms and the entities that you find there.

Portal

A portal is like an exit pathway which takes a player from one room to another. It is the only new model we will need to complete this lesson.

import Foundation
import SQLite

struct Portal {
    
    // MARK: - Fields
    static let component_id: Int64 = 2
    static let table = Table("Portals")
    static let portal_id_column = Expression<Int64>("id")
    static let from_room_id_column = Expression<Int64>("from_room_id")
    static let to_room_id_column = Expression<Int64>("to_room_id")
    static let direction_column = Expression<String>("direction")
    static let message_column = Expression<String?>("message")
    let row: Row
    
    // MARK: - Properties
    var id: Int64 {
        get {
            return row[Portal.portal_id_column]
        }
    }
    
    var fromRoomID: Int64 {
        get {
            return row[Portal.from_room_id_column]
        }
    }
    
    var toRoomID: Int64 {
        get {
            return row[Portal.to_room_id_column]
        }
    }
    
    var direction: String {
        get {
            return row[Portal.direction_column]
        }
    }
    
    var message: String? {
        get {
            return row[Portal.message_column]
        }
    }
    
    var entity: Entity? {
        get {
            return Entity.fetchByComponentID(Portal.component_id, dataID: id)
        }
    }
}

extension Entity {
    func getPortals() -> [Portal] {
        let components = getComponents(Portal.component_id)
        let ids = components.map({ $0.dataID })
        let table = Portal.table.filter(ids.contains(Portal.portal_id_column))
        let result = DataManager.instance.prepare(table).map({ Portal(row: $0) })
        return result
    }
}

Portal System

As you might have imagined, since there is a portal model, there will also be a portal system.

import Foundation

class PortalSystem {
    // MARK: - Public
    class func setup() {
        InterpreterSystem.instance.register(PortalAction())
    }
    
    // TODO: Add code here
}

This system will include a setup phase which will register commands that allow a player to navigate by specifying the direction that corresponds to a portal to activate.

class func activate(portal: Portal) -> Bool {
    guard portal.toRoomID != 0 else {
        return false
    }
    RoomSystem.move(portal.toRoomID)
    return true
}

When a valid portal action has been executed, it will call this method to determine what room, if any, to move the player to. Some portals will not actually have a valid room target because that direction is blocked. In the future we will show how triggers can unlock a portal.

class func fetchMatchingPortal(direction: String) -> Portal? {
    guard let room = RoomSystem.room.entity else { return .None }
    let portals = room.getPortals()
    for portal in portals {
        if matchesPortal(portal, direction: direction) {
            return portal
        }
    }
    return .None
}

This convenient method will look for a portal in the current room that matches the indicated direction. The provided direction can be a fully typed cardinal direction like “north” or “southwest” or an abbreviation such as “E” or “NW”. You can also travel “up” and “down” at some locations.

// MARK: - Private
private class func matchesPortal(portal: Portal, direction: String) -> Bool {
    let directions = portal.direction.componentsSeparatedByString(",")
    for path in directions {
        if path == direction {
            return true
        }
    }
    return false
}

The portal’s direction field in the database actually stores a comma separated list. This is because the portals in Zork can be used by more than one direction. For example, from the starting room “West of House” you can travel “North” or “Northeast” to reach the “North of House” room. If you look at the map you can kind of see that the layout of the rooms is in a circle around the house, so either direction can sort of make sense.

Personally I found it a confusing design because I felt that anytime I travel in a particular direction I would expect the opposite direction to take me back to my previous location. When I go “North” to “North of House” I expect “South” to return me to “West of House” but that is not what you get. I initially played without a map and was immediately lost.

Room System

Open up the room system so we can extend its functionality.

class func move(roomID: Int64) {
    guard let presence = PlayerSystem.player.getPresences().first else { fatalError("Expected presence component on player") }
    guard let room = RoomSystem.fetchByID(roomID) else { fatalError("Invalid portal target") }
    move(presence, roomID: roomID)
    SightSystem.describeRoom(room, verbose: false)
    visit(room)
}

This method is invoked from the Portal system after activating a portal. It causes the player to be moved to a new location. It also prints a room title and description to the screen, unless you have already visited the room before and then it should only print the room title. Afterwards, it calls the “visit” method which updates the database to show that the room has now been visited.

class func move(presence: Presence, roomID: Int64) {
    let update = Presence.table.filter(Presence.presence_id_column == presence.id).update(Presence.room_id_column <- roomID)
    DataManager.instance.run(update)
}

This method allows you to move any entity with a presence component to the specified room id. Note that the room ID doesnt have to be valid, and is one way that you can hide entities that are not needed.

class func visit(room: Room) {
    let update = Room.table.filter(Room.room_id_column == room.id).update(Room.visited_column <- true)
    DataManager.instance.run(update)
}

The “visit” method actually performs the update in the database to toggle the flag on the appropriate row. Behind the scenes this will end up being translated to an Integer value of 0 or 1, but we are able to work with a type with a clearer intent.

class func entitiesInRoom() -> [Entity] {
    var result = entitiesPresentInRoom()
    result.appendContentsOf(entitiesContainedByRoom())
    return result
}

// MARK: - Private
private class func entitiesPresentInRoom() -> [Entity] {
    let table = Presence.table.filter(Presence.room_id_column == RoomSystem.room.id)
    let presences = DataManager.instance.prepare(table).map({ Presence(row: $0) })
    var result: [Entity] = []
    for item in presences {
        guard let entity = item.entity else { continue }
        result.append(entity)
    }
    return result
}

private class func entitiesContainedByRoom() -> [Entity] {
    guard let roomEntity = room.entity else { return [] }
    return ContainmentSystem.fetchContainedEntities(roomEntity)
}

The “entitiesInRoom” method builds a list of all game entities that appear within the current room. Currently two different components could imply that an entity is located there: the presence and containable component. Therefore I make a single array which appends the results of “entitiesPresentInRoom” (for presence components) and “entitiesContainedByRoom” (for containable components).

Sight System

Since we are enabling the ability to travel the game world, it seems necessary that we also provide a way to look at it. Let’s open up the Sight System and extend it with some more methods:

class func setup() {
    InterpreterSystem.instance.register(LookAction())
}

Here we will add a setup method to register some more game commands. This action will allow us to look both at the current room or at an entity located within the room.

class func describeContainerContents(entity: Entity) -> String? {
    let containerName = LabelSystem.describe(entity)
    let items = SightSystem.listContainerContents(entity)
    guard items.count > 0 else { return .None }
    return "The \(containerName) contains:\n  \(items.joinWithSeparator("\n  "))"
}

If we should use the look command to inspect a container entity, then this method will print out the list of entities which it currently contains.

class func describeRoom(room: Room, verbose: Bool) {
    var message = room.visited == false || verbose ? "\(room.title)\n\(room.description)" : room.title
    message = appendDescription(message, ofContentsOfRoom: room)
    LoggingSystem.instance.addLog(message)
}

The describe room method is invoked whenever we use the look command without a target. It prints the room title and description even if we had already visited the room before. In addition, it will list any entities that are considered “notable”.

class func examine(entity: Entity, userDescription: String?) -> String {
    let label = userDescription ?? LabelSystem.describe(entity)
    if let openable = entity.getOpenable() {
        if openable.isOpen == false {
            return "The \(label) is closed."
        } else if entity.getContainer() == nil {
            return "The \(label) is open."
        }
    }
    
    if let _ = entity.getContainer(), contents = describeContainerContents(entity) {
        return contents
    }
    
    return "There's nothing special about the \(label)."
}

Here I have provided a simple implementation for the ability to “examine” or “look at” an entity. Some of the special traits such as whether or not it is “open” will be listed, or if the entity is a container it can list the contents, otherwise it will print a generic response that there is no trait that matters.

private class func appendDescription(description: String, ofContentsOfRoom room: Room) -> String {
    var updatedDescription = description
    let entities = RoomSystem.entitiesInRoom()
    for entity in entities {
        guard entity.id != PlayerSystem.player.id else { continue }
        if let _ = entity.getNotable() {
            updatedDescription += appendDescription(entity)
        }
        if let _ = entity.getContainer(), contents = describeContainerContents(entity) {
            updatedDescription += "\n\(contents)"
        }
    }
    return updatedDescription
}

This method takes the initial description of a room and appends the description of the contents of the room. This way it can be logged as a single message.

private class func appendDescription(entity: Entity) -> String {
    let itemDescription = LabelSystem.describe(entity)
    return "\nThere is a \(itemDescription) here."
}

Here I provide a generic message for the description of a single entity.

Master System

Now that we have several systems that require setup, we will need to update the master system. Update the setup method as follows:

class func setup() {
    SightSystem.setup()
    PortalSystem.setup()
    OpenableSystem.setup()
}

Actions

We’ve got the necessary models and systems in place, so now we can add some actions which allow us to explore our world!

Portal Action

Like I have done before, the command which is actually registered for this is a subclass of the compound action. In this case the compound action only has a single action within it, which makes it feel a little less valuable. However, I could see the desire to expand on this, such as if I wanted to support some more keywords like “enter” or “exit”.

The base portal action, which works simply by specifying a direction or the abbreviation for a direction works pretty simply by converting any fully specified direction to its abbreviated form, and then using that with the portal system to find a matching portal. If there is a match, then we attempt to activate it, otherwise we use a generic messge indicating that you can’t go the way you requested.

import Foundation

class PortalAction: CompoundAction {
    init() {
        let allCommands = BasePortalAction.commands.union(BasePortalAction.abbreviate.keys)
        let action = BasePortalAction(commands: allCommands, specifiers: [], primaryTargetMode: .Zero, secondaryTargetMode: .Zero)
        super.init(actions: [action])
    }
}

private class BasePortalAction: BaseAction {
    static let commands: Set<String> = ["N", "E", "S", "W", "NE", "SE", "SW", "NW", "U", "D"]
    static let abbreviate = [ "NORTH" : "N", "EAST" : "E", "SOUTH" : "S", "WEST" : "W", "NORTHEAST" : "NE", "SOUTHEAST" : "SE", "SOUTHWEST" : "SW", "NORTHWEST" : "NW", "UP" : "U", "DOWN" : "D" ]
    
    private override func handle(interpretation: Interpretation) {
        let command = BasePortalAction.abbreviate[interpretation.command] ?? interpretation.command
        guard commands.contains(command) else { return }
        
        let portal = PortalSystem.fetchMatchingPortal(command)
        guard let match = portal where PortalSystem.activate(match) else {
            let message = portal?.message ?? "You can't go that way."
            LoggingSystem.instance.addLog(message)
            return
        }
    }
}

Look Action

This action makes better use of the compound action base class. It supports the ability to look at a room (when specifying no target) or an entity (when specifying one target). It also supports a synonym for “look” by implementing “examine”.

import Foundation

class LookAction: CompoundAction {
    init() {
        let lookAtRoom = LookAtRoom(commands: ["LOOK"], specifiers: [], primaryTargetMode: .Zero, secondaryTargetMode: .Zero)
        let lookAtTarget = LookAtTarget(commands: ["LOOK"], specifiers: ["AT"], primaryTargetMode: .Single, secondaryTargetMode: .Zero)
        let examineTarget = LookAtTarget(commands: ["EXAMINE"], specifiers: [], primaryTargetMode: .Single, secondaryTargetMode: .Zero)
        super.init(actions: [lookAtRoom, lookAtTarget, examineTarget])
    }
}

private class LookAtRoom: BaseAction {
    private override func handle(interpretation: Interpretation) {
        SightSystem.describeRoom(RoomSystem.room, verbose: true)
    }
}

private class LookAtTarget: BaseAction {
    private override func handle(interpretation: Interpretation) {
        guard let target = interpretation.primary.first else { return }
        TargetingSystem.filter(target, options: [TargetingFilter.CurrentRoom])
        TargetingSystem.validate(target)
        
        guard let match = target.match where target.error == .None else {
            guard let error = target.error else { return }
            LoggingSystem.instance.addLog(error)
            return
        }
        
        let message = SightSystem.examine(match, userDescription: target.userInput)
        LoggingSystem.instance.addLog(message)
    }
}

Demo

We wont need any temporary code for our demo this time. Simply build and run and start trying out the new commands. Look at your room, or an entity in the room, or try to navigate around the world. You can visit almost every room in the database, although entering the house requires us to implement some special triggers.

ECS_Zork_07_01_Demo_zpswyesxvki

Summary

This was a pretty simple, but important lesson. Most of what I presented was pretty standard, such as creating models to represent the tables in our database, or providing a system to work with that data. However, when you compile enough of this work together you end up with a very large and rewarding experience. By completing this lesson we are now able to explore our game world, and to a small degree we can already interact with some of it as well.

Don’t forget that this project is accompanied by a repository HERE. There will be one commit per lesson so you can see exactly how the project would have been cofigured and how the code would look at each step.

Leave a Reply

Your email address will not be published. Required fields are marked *