読者です 読者をやめる 読者になる 読者になる

いものやま。

雑多な知識の寄せ集め

変種オセロのUIを作ってみた。(その17)

昨日はPassボタンの対応を行った。

今日はUndoボタンの対応を行っていく。

BoardNodeの修正

まずは、Undo自体が出来るようにするために、BoardNodeを修正していく。

オペレーションキューの追加

今までの実装では手を抜いてたけど、Undoを出来るようにするために一つ必要なのが、並列性に関する設計。

というのも、今までは状態における入力元が高々1つ(ボードもしくはPassボタン)に限られていたけれど、Undoを可能にすると、入力元が2つ(ボードとUndoボタン)に増えることになるから。
そうなると、タイミングによってはボードに対して複数の要求がいっぺんにやってくる可能性が出てくる。
なので、ちゃんと並列性の設計をしていないと、ボードの状態が狂ってしまう可能性がある。

具体的に必要なのは、ボードの状態を変更するメソッド排他制御
ボードの状態を変更している途中で、他のメソッドによって同様にボードの状態が変更されてしまうということを防がなければならない。
そうやって操作のアトミック性(一貫性)が確保できれば、ボードの状態が不正になるということを防ぐことが出来る。

メソッド排他制御を行う一つの方法はミューテックス(やセマフォ)を使うことだけど、iOSの場合、オペレーションキュー(やディスパッチキュー)を使うことでも実現できる。

public class BoardNode: SKSpriteNode {
  // 省略
  
  private var actionQueue: NSOperationQueue

  public init(board: Board) {
    // 省略

    self.actionQueue = NSOperationQueue()
    self.actionQueue.maxConcurrentOperationCount = 1

    // 省略
  }

  // 省略
}

NSOperationQueue#maxConcurrentOperationCountを1にするのがポイントで、こうすることで同時に実行されるオペレーションが高々1つに限定される。
(ただし、ここに追加するオペレーションの中で、他のオペレーションの終了を待ったりしてはいけない。デッドロックするから)

play、change、passの修正

オペレーションキューを追加したので、それにあわせてplay、change、passを修正していく。

public class BoardNode: SKSpriteNode {
  // 省略
  
  public func play(row: Int, _ col: Int) {
    self.actionQueue.addOperationWithBlock {
      if !self.board.isPlayable(row, col) {
        return
      }
      
      let originalBoard = self.board
      self.previousBoards.append(originalBoard)
      self.previousActions.append(Board.Action.Play(row, col))
      self.board = self.board.play(row, col)
      
      let pieceStatus = (originalBoard.turn == .Bad) ? PieceNode.Status.Bad : PieceNode.Status.Good
      self.squares[row][col].addPiece(pieceStatus)
      self.squares[row][col].piece.fadeInWithCompletion {
        let duration = self.updateSquaresFrom(originalBoard, to: self.board, except: row, col)
        
        let waitAction = SKAction.waitForDuration(duration)
        self.runAction(waitAction) {
          self.notifyObserversActionIsFinished(self.board)
        }
      }
    }
  }
  
  public func change(row: Int, _ col: Int) {
    self.actionQueue.addOperationWithBlock {
      if !self.board.isChangeable(row, col) {
        return
      }
      
      let originalBoard = self.board
      self.previousBoards.append(originalBoard)
      self.previousActions.append(Board.Action.Change(row, col))
      self.board = self.board.change(row, col)
      
      let pieceStatus = (originalBoard.turn == .Bad) ? PieceNode.Status.Bad : PieceNode.Status.Good
      self.squares[row][col].piece.changeTo(pieceStatus) {
        let duration = self.updateSquaresFrom(originalBoard, to: self.board, except: row, col)
        
        let action = SKAction.moveTo(BoardNode.tokenPosition[self.board.token]!, duration: 0.5)
        self.token.runAction(action)

        let waitAction = SKAction.waitForDuration(max(duration, NSTimeInterval(0.5)))
        self.runAction(waitAction) {
          self.notifyObserversActionIsFinished(self.board)
        }
      }
    }
  }
  
  public func pass() {
    self.actionQueue.addOperationWithBlock {
      if !self.board.mustPass {
        return
      }
      
      let originalBoard = self.board
      self.previousBoards.append(originalBoard)
      self.previousActions.append(Board.Action.Pass)
      self.board = self.board.pass()
      
      self.notifyObserversActionIsFinished(self.board)
    }
  }

  // 省略
}

各操作をオペレーションキューに追加するようにすることで、それぞれの操作を行っている途中に他の操作が行われてしまうことを防いでいる。
また、元はassertを入れていたけれど、オペレーションキューに入れるようにしたことで、メソッドが呼ばれたタイミングと実際に操作が実行されるタイミングでズレが生じる可能性が出てきたため、assertは無くして、不正な状態での呼び出しは何もせずに操作が終了するように修正してある。

Undoの実装

さて、本題のUndoの実装。

public class BoardNode: SKSpriteNode {
  // 省略
  
  public func undo() {
    self.actionQueue.addOperationWithBlock {
      if self.previousActions.count == 0 {
        return
      }
      
      let currentBoard = self.board
      let lastAction = self.previousActions.removeLast()
      let previousBoard = self.previousBoards.removeLast()
      self.board = previousBoard
      
      switch lastAction {
      case let .Play(row, col):
        self.squares[row][col].piece.fadeOutWithCompletion {
          self.squares[row][col].removePiece()
          let duration = self.updateSquaresFrom(currentBoard, to: previousBoard, except: row, col)
          
          let waitAction = SKAction.waitForDuration(duration)
          self.runAction(waitAction) {
            self.notifyObserversActionIsFinished(self.board)
          }
        }
      case let .Change(row, col):
        self.squares[row][col].piece.changeTo(.Common) {
          let duration = self.updateSquaresFrom(currentBoard, to: previousBoard, except: row, col)
          
          let action = SKAction.moveTo(BoardNode.tokenPosition[previousBoard.token]!, duration: 0.5)
          self.token.runAction(action)
          
          let waitAction = SKAction.waitForDuration(max(duration, NSTimeInterval(0.5)))
          self.runAction(waitAction) {
            self.notifyObserversActionIsFinished(self.board)
          }
        }
      case .Pass:
        self.notifyObserversActionIsFinished(self.board)
      }
    }
  }
  
  // 省略
}

といっても、手とボードの履歴を内部で持っているので、やっていることは簡単。
直前の手とボードを取り出して、巻き戻しているだけ。

なお、もしRedoを可能にしたければ、Redo用のスタックを用意しておいて、そこに現在のボードと手をプッシュしてあげればいい。
(この場合、アクションを行ったときにRedo用のスタックを空にしてリセットしておく必要があることに、ちょっと注意が必要)

付随的な修正

Undoの実装にともなって、もうちょっと修正が必要で、以下はその内容。

public class BoardNode: SKSpriteNode {
  // 省略
  
  public func lightUpSquare(row: Int, _ col: Int, withOpponent: Bool = false) {
    let turn = withOpponent ? self.board.opponent : self.board.turn
    self.squares[row][col].changeStatusTo(.Lighted, withTurn: turn)
  }

  // 省略
  
  public var lastAction: Board.Action? {
    let count = self.previousActions.count
    if count > 0 {
      return self.previousActions[count - 1]
    } else {
      return nil
    }
  }
  
  // 省略
}

まず、BoardNode#lightUpSquare(_: Int, _: Int)にwithOpponentというオプションの引数を追加。

これはどういうことかというと、順方向の変更(アクションを選択→ボードの状態を変更)であれば、アクションを選択した時点での手番の色で選択されたマスをライトアップすればよかったのに対し、逆方向の変更(ボードの状態を戻す→直前のアクションを再現する)だと、直前のアクションで選択されたマスのライトアップを行おうとしたときに、直前のマスの選択を行ったプレイヤーが現在のボードの手番プレイヤーではないので、手番プレイヤーの相手の色でライトアップを行う必要があったから。

また、同様に、直前のアクションを再現するために、直前のアクションがなんだったのかを知る必要があったので、BoardNode#lastActionという計算型プロパティを追加している。

BoardNodeの修正は、こんなところ。
次はTurnControllerを修正して、実際にUndoを行えるようにしていく。

今日はここまで!