Skip to content
/ Maisie Public

An iOS app that programmatically renders a maze using tiles sourced from a REST API

License

Notifications You must be signed in to change notification settings

jmromer/Maisie

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Maisie

BuddyBuild

Dependencies

Local development

The latter three dependencies are managed by CocoaPods. To run the app locally, issue the following from the project root:

$ pod install

Then open Maisie.xcworkspace.

Automated tests

A test suite is included using XCTest.

// MaisieTests/MazeAPITests.swift L15-L28 (235cf32b)

class MazeAPITests: XCTestCase {
  var mazeManager = MockMazeManager()

  override func setUp() {
    super.setUp()
    MazeAPI.shared.mazeManager = self.mazeManager
  }

  func testSubsequentCallsForTheSameRoomAreFetchedFromCache() {
    _ = MazeAPI.shared.fetchRoom(roomId: self.mazeManager.mockRoomID)
    _ = MazeAPI.shared.fetchRoom(roomId: self.mazeManager.mockRoomID)
    XCTAssertEqual(self.mazeManager.timesCalled("fetchRoom"), 1)
  }
}
MaisieTests/MazeAPITests.swift#L15-L28 (235cf32b)

Demo

maze generation

Implementation walkthrough

// Maisie/Controllers/MazeVC.swift L11-L26 (235cf32b)

class MazeVC: UIViewController {
  @IBOutlet weak var collectionView: UICollectionView!
  @IBOutlet weak var loadingIndicator: UIActivityIndicatorView!
  @IBOutlet weak var timeLabel: UILabel!
  @IBOutlet weak var generateButton: UIButton!

  // keep track of how long the maze generation takes to complete
  let mazeTimer = Timer()

  // update the layout's number of columns as the maze is built
  var mazeViewFlowLayout = MazeViewFlowLayout()

  let maze = Maze.shared
  var rooms = [[Room?]]() {
    didSet { collectionView.reloadData() }
  }
Maisie/Controllers/MazeVC.swift#L11-L26 (235cf32b)
// Maisie/Controllers/MazeVC.swift L43-L45 (235cf32b)

  @IBAction func generateButtonWasPressed(_ sender: UIButton) {
    self.maze.generate()
  }
Maisie/Controllers/MazeVC.swift#L43-L45 (235cf32b)
// Maisie/Models/Maze.swift L40-L54 (235cf32b)

  /// Generate a new maze using the API.
  /// Resets any current state.
  /// Will no-op if a maze is currently generating.
  /// Notifies delegate via `didBeginLoadingMaze`.
  func generate() {
    guard !isLoading else { return }
    reset()
    delegate?.didBeginLoadingMaze()
    isLoading = true

    firstly { MazeAPI.shared.startRoom() }
      .then { roomId in MazeAPI.shared.fetchRoom(roomId: roomId) }
      .then { startRoom in self.fetchNeighbors(rooms: [startRoom]) }
      .catch { print("Error: \($0)") }
  }
Maisie/Models/Maze.swift#L40-L54 (235cf32b)
// Maisie/Models/MazeAPI.swift L47-L60 (235cf32b)

  func fetchRoom(roomId: String, coordinates: Coordinates? = nil) -> Promise<Room> {
    return Promise { (fulfill, reject) in
      // If room has already been encountered, return its cached copy
      // without making an API call
      if let room = MazeCache.shared.get(roomWithId: roomId) {
        fulfill(room)
        return
      }

      // Query the API for the given room ID's associated room attributes
      self.mazeManager.fetchRoom(withIdentifier: roomId) { (data, error) in
        if let error = error {
          return reject(error)
        }
Maisie/Models/MazeAPI.swift#L47-L60 (235cf32b)
// Maisie/Models/MazeCache.swift L11-L16 (235cf32b)

final class MazeCache {
  static let shared = MazeCache()
  private init() {}

  var roomsById = NSCache<NSString, Room>()
  var roomsByCoordinate = NSCacheCache<Coordinates, Room>()
Maisie/Models/MazeCache.swift#L11-L16 (235cf32b)
// Maisie/Models/Maze.swift L70-L93 (235cf32b)

  /// Performs a depth-first traversal of the room graph, recursively
  /// fetching new adjacent rooms. Triggers lifecycle methods on the delegate.
  private func fetchNeighbors(rooms: [Room]) {
    if rooms.isEmpty {
      self.isLoading = false
      self.delegate?.didEndLoadingMaze()
      return
    }

    // set "maze is loading" state. Used to determine if a new maze can
    // currently be generated.
    self.isLoading = true

    // Notify delegate a traversal has begun, add any given rooms to the grid
    delegate?.didBeginTraversal()
    add(rooms: rooms)

    // recurse on each of the neighboring rooms once their
    // promises are fulfilled.
    join(rooms.flatMap({ $0.neighboringRooms }))
      .then { rooms in self.fetchNeighbors(rooms: rooms) }
      .always { self.delegate?.didEndTraversal() }
      .catch { error in print("Error during graph traversal: \(error)") }
  }
Maisie/Models/Maze.swift#L70-L93 (235cf32b)
// Maisie/Models/Room.swift L22-L37 (d2cb1090)

  /// Pointers to adjacent rooms that have not already been encountered.
  /// Whether or not a room has been encountered is determined by checking
  /// the maze cache. This check is needed in order to stop the graph traversal
  /// that builds the maze.
  var newNeighbors: [RoomPointer] {
    return neighbors.filter { roomPointer in
      !MazeCache.shared.contains(roomWithId: roomPointer.targetId)
    }
  }

  /// Promises returning adjacent Rooms that have not been encountered before.
  var neighboringRooms: [Promise<Room>] {
    return newNeighbors.map { roomPointer in
      roomPointer.getRoom(from: self)
    }
  }
Maisie/Models/Room.swift#L22-L37 (d2cb1090)
// Maisie/Models/RoomPointer.swift L45-L54 (d2cb1090)

  /// Return a Promise to the Room pointed to by this RoomPointer.
  func getRoom(from origin: Room) -> Promise<Room> {
    return fetchRoom(roomId: targetId, originCoordinates: origin.coordinates)
  }

  fileprivate func fetchRoom(roomId: String, originCoordinates coords: Coordinates)
    -> Promise<Room> {
    let target = Coordinates.inDirection(self.direction, fromOrigin: coords)
    return MazeAPI.shared.fetchRoom(roomId: roomId, coordinates: target)
  }
Maisie/Models/RoomPointer.swift#L45-L54 (d2cb1090)
// Maisie/Models/RoomPointer.swift L57-L74 (d2cb1090)

class LockedRoomPointer: RoomPointer {
  /// A specialization of `getRoom` that first unlocks the given room to get
  /// the room ID, then returns a promise that resolves to that Room.
  ///
  /// - Parameters:
  ///   - origin: The origin Room from which to understand the pointer's
  ///             direction.
  ///
  override func getRoom(from origin: Room) -> Promise<Room> {
    return firstly {
      return MazeAPI.shared.unlockRoom(lockId: targetId)
    }.then { roomId in
      return self.fetchRoom(roomId: roomId, originCoordinates: origin.coordinates)
    }.catch { error in
      print("Error: \(error)")
    }
  }
}
Maisie/Models/RoomPointer.swift#L57-L74 (d2cb1090)
// Maisie/Controllers/MazeVC.swift L48-L79 (235cf32b)

// MARK: MazeDelegate
extension MazeVC: MazeDelegate {
  func didBeginLoadingMaze() {
    mazeTimer.startTiming()
    loadingIndicator.startAnimating()
    generateButton.isEnabled = false
    loadingIndicator.isHidden = false
    timeLabel.isHidden = false
  }

  func didBeginTraversal() {
    timeLabel.text = mazeTimer.durationString
  }

  func didUpdateMaze(grid: [[Room?]]) {
    rooms = grid
    guard let columns = grid.first?.count else { return }
    // update flow layout with the number of columns now present
    mazeViewFlowLayout.update(columns: columns)
    collectionView.collectionViewLayout = mazeViewFlowLayout
  }

  func didEndTraversal() {
    timeLabel.text = mazeTimer.durationString
  }

  func didEndLoadingMaze() {
    loadingIndicator.stopAnimating()
    loadingIndicator.isHidden = true
    generateButton.isEnabled = true
  }
}
Maisie/Controllers/MazeVC.swift#L48-L79 (235cf32b)
// Maisie/Controllers/MazeVC.swift L101-L116 (d2cb1090)

  func collectionView(_ collectionView: UICollectionView,
                      cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let room = rooms[indexPath.section][indexPath.row]

    if let room = room {
      let cell = collectionView
        .dequeueReusableCell(withReuseIdentifier: MazeCell.reuseID,
                             for: indexPath) as! MazeCell
      cell.configure(room: room)
      return cell
    } else {
      let emptyCell = collectionView
        .dequeueReusableCell(withReuseIdentifier: EmptyMazeCell.reuseID,
                             for: indexPath) as! EmptyMazeCell
      return emptyCell
    }
Maisie/Controllers/MazeVC.swift#L101-L116 (d2cb1090)
// Maisie/Models/Maze.swift L56-L64 (64bad87c)

  /// Add a room to the known maze.
  /// Stores the new room's coordinates, then regenerates the maze grid
  /// from the set of all currently known coordinates.
  /// Notifies the delegate that the maze has been updated.
  func add(room: Room) {
    roomCoordinates.insert(room.coordinates)
    regenerateGrid()
    delegate?.didUpdateMaze(grid: grid)
  }
Maisie/Models/Maze.swift#L56-L64 (64bad87c)

About

An iOS app that programmatically renders a maze using tiles sourced from a REST API

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published