いものやま。

雑多な知識の寄せ集め

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

昨日の続き。

Playerプロトコルの修正

Undoに対応するためにPlayerプロトコルに少し修正を加える。

//==============================
// YWF
//------------------------------
// Player.swift
//==============================

public protocol Player {
  var isCom: Bool { get }
  func select(board: Board) -> Board.Action?
  func cancelSelect()
}

Player#isComという計算型プロパティと、Player#cancelSelect()というメソッドを追加している。

これは、

  • Undoでアクションの巻き戻しを行う場合、人間の手番までアクションを巻き戻す必要がある。
  • アクションの選択中にUndoボタンが押された場合、そのアクションの選択をキャンセルする必要がある。

という理由から。

Humanクラスの修正

Playerプロトコルの修正にともなって、Humanクラスにも修正を入れる。

//==============================
// YWF
//------------------------------
// Human.swift
//==============================

import Foundation

public class Human: Player, BoardNodeObserver, ButtonNodeObserver {
  public var isCom: Bool {
    return false
  }

  // 省略
  
  private func notifyInput() {
    self.queue.cancelAllOperations()
    if self.semaphore != nil {
      self.queue.addOperation(self.semaphore)
    }
  }
  
  public func cancelSelect() {
    self.notifyInput()
  }
}

キャンセルされたときには、単に入力があったことを通知して、アクションの選択待ち状態を止めるようにしている。
もちろん、そうなるとselec()で返されるアクションの値は不正で信頼できないものになるけれど、そこはTurnControllerの方で対処する。
それに、仮にTurnControllerでその不正なアクションを止められなかったとしても、不正なアクションはBoardNodeで無視されるので、特に問題はない。

一つ気をつけたいのが、Human#notifyInput()でセマフォnilチェックを行うようにしているということ。
今までであれば、必ず入力待ちと入力があったことの通知が一対一対応していたので、このnilチェックは不要だったんだけど、Undoボタンが押されたときにも通知が来るようになったので、必ずしも一対一対応するとは限らなくなっている。
例えば、入力待ちになる→手が選択される→入力があったことの通知がされる→入力待ちが解除され、セマフォnilになる(けどまだselect()からは戻っていない)→Undoボタンが押される→入力があったことが通知される→(まだselect()から戻ってきてないので)入力待ちの解除を行おうとする、という流れが発生すると、nilチェックを行っていない場合、nilをオペレーションキューに突っ込むことになってしまう。

AlphaBetaComクラスの修正

AlphaBetaComクラスについても、同様の修正を入れる。

//==============================
// YWF
//------------------------------
// AlphaBetaCom.swift
//==============================

public class AlphaBetaCom: Player {
  public var isCom: Bool {
    return true
  }
  
  // 省略
  
  private var cancelled: Bool
  
  // 省略

  public func select(board: Board) -> Board.Action? {
    self.cancelled = false
    
    var currentSelect: Board.Action! = .Pass
    var currentSelectValue = -1000
    var opponentBestValue = 1000
    for action in board.legalActions {
      if self.cancelled {
        break
      }
      
      let value = self.calculateActionValue(board, action, self.depth,
                                            currentSelectValue, opponentBestValue)
      if value > currentSelectValue {
        currentSelect = action
        currentSelectValue = value
        if currentSelectValue >= opponentBestValue {
          break
        }
      }
    }
    
    switch currentSelect! {
    case .Pass:
      println("pass.")
    case let .Play(row, col):
      println("play (\(row), \(col)).")
    case let .Change(row, col):
      println("change (\(row), \(col)).")
    }
    return currentSelect!
  }

  private func calculateActionValue(board: Board, _ action: Board.Action, _ depth: Int,
                                    _ selfBestValue: Int, var _ opponentBestValue: Int) -> Int {
    if self.cancelled {
      return 0
    }
    
    let newBoard: Board
    switch action {
    case .Pass:
      newBoard = board.pass()
    case let .Play(row, col):
      newBoard = board.play(row, col)
    case let .Change(row, col):
      newBoard = board.change(row, col)
    }

    if (depth == 1) || newBoard.isGameEnd {
      if newBoard.win(self.status) {
        return 100
      } else if newBoard.win(self.opponent) {
        return -100
      } else {
        return newBoard.count(self.status) - newBoard.count(self.opponent)
      }
    } else {
      let sign = (newBoard.turn == self.status) ? 1 : -1

      for newAction in newBoard.legalActions {
        if self.cancelled {
          return 0
        }
        
        let value = self.calculateActionValue(newBoard, newAction, depth - 1,
                                              opponentBestValue, selfBestValue)
        if (value * sign) > (opponentBestValue * sign) {
          opponentBestValue = value
        }

        if (opponentBestValue * sign) >= (selfBestValue * sign) {
          break
        }
      }

      return opponentBestValue
    }
  }
  
  public func cancelSelect() {
    self.cancelled = true
  }
}

cancelledというプロパティを追加して、キャンセルされた場合、この値がtrueになるようにしている。
そして、アクションの選択中にこのプロパティをこまめにチェックするようにして、trueになっていたらselect()から抜け出すようにしてある。
(こういうときに例外処理の仕組みがないと非常に不便。例外があれば例外を投げて一番上側でキャッチすればいいだけなのに・・・)

TurnControllerの修正

さて、いよいよTurnControllerの修正。

プロパティとイニシャライザ

Undoに対応させるために、プロパティとイニシャライザを少し修正。

//==============================
// YWF
//------------------------------
// TurnController.swift
//==============================

import Foundation

public class TurnController: BoardNodeObserver, ButtonNodeObserver {
  private var boardNode: BoardNode
  private var undoButton: ButtonNode
  
  private var badPlayer: Player
  private var goodPlayer: Player
  
  private var queue: NSOperationQueue
  private var selectOperation: NSOperation!
  private var undoOperation: NSOperation!
  
  public init(boardNode: BoardNode, undoButton: ButtonNode,
              badPlayer: Player, goodPlayer: Player) {
    self.boardNode = boardNode
    self.undoButton = undoButton
    self.badPlayer = badPlayer
    self.goodPlayer = goodPlayer
    self.queue = NSOperationQueue()
    self.selectOperation = nil
    self.undoOperation = nil
  }

  // 続く

まず、プロパティにboardNodeとundoButtonを追加してある。

なお、boardNodeを追加したのは、Undoボタンを押されたときにボードに対してUndoを要求しないといけないのだけど、通知がボードからではなくボタンからくるので、通知内でボードへの参照が得られないから。
また、undoButtonについても、アクションの選択を行うときに、Undoボタンの有効/無効を切り替える必要がある場合があるので、やはり参照を保持しておく必要がある。

それと、オペレーションオブジェクトを参照するためのプロパティとして、selectOperationとundoOperationが追加してある。

ゲームの開始

  // 続き
  
  public func start() {
    self.boardNode.addObserver(self)
    self.undoButton.addObserver(self)
    
    self.selectOperation = NSBlockOperation {
      self.requestAction()
    }
    self.queue.addOperation(self.selectOperation)
  }

  // 続く

ボードとUndoボタンへの参照を持つようにしたので、start()は引数としてボードへの参照を必要としなくなっている。

アクションの選択と実行

Undoボタンを押されることでアクションがキャンセルされる可能性が出ているので、以下のように修正。

  // 続き

  private func requestAction() {
    self.boardNode.lightUpEnableSquares()
    
    if self.boardNode.board.move > 0 {
      self.undoButton.enable()
    } else {
      self.undoButton.disable()
    }
    
    let board = self.boardNode.board
    let action: Board.Action
    switch board.turn {
    case .Bad:
      action = self.badPlayer.select(board)!
    case .Good:
      action = self.goodPlayer.select(board)!
    default:
      action = .Pass
    }
    
    if !self.selectOperation.cancelled {
      self.boardNode.lighDown()
      
      switch action {
      case let .Play(row, col):
        self.boardNode.lightUpSquare(row, col)
        self.boardNode.play(row, col)
      case let .Change(row, col):
        self.boardNode.lightUpSquare(row, col)
        self.boardNode.change(row, col)
      case .Pass:
        self.boardNode.pass()
      }
    }
  }
  
  // 続く

アクションの選択でプレイヤーから返ってきた手を実行する前に、アクションの選択自体がキャンセルされていたら、何もしないようしている。

Undoボタンへの対応

Undoボタンが押されたときの処理は、以下のとおり。

  // 続き

  public func buttonIsSelected(button: ButtonNode) {
    switch button.type {
    case .Undo:
      self.undoButton.disable()
      self.cancelSelectOperation()
      self.undoOperation = NSBlockOperation {
        self.boardNode.lighDown()
        self.boardNode.undo()
      }
      self.queue.addOperation(self.undoOperation)
    default:
      break
    }
  }
  
  private func cancelSelectOperation() {
    if (self.selectOperation != nil) &&
       (!self.selectOperation.finished) {
      self.selectOperation.cancel()
      switch self.boardNode.board.turn {
      case .Bad:
        self.badPlayer.cancelSelect()
      case .Good:
        self.goodPlayer.cancelSelect()
      default:
        break
      }
    }
  }

  // 続く

まず、アクションの選択のオペレーションをキャンセル。
(ただし、オペレーションをキャンセルをしても、無理やりそのオペレーションが止められるわけではないのに注意。Swiftでの並列プログラミングについて調べてみた。(その4) - いものやま。も参照)

そして、アクションの選択を委譲しているプレイヤーにも選択をキャンセルさせる。

そのあと、ボードに対してUndoを要求している。

BoardNodeObserverプロパティへの準拠

Undoに対応するために、BoardNodeObserver#boardNodeActionIsFinished(_: BoardNode)の修正を行う。

  // 続き
  
  public func boardNodeActionIsFinished(node: BoardNode) {
    if self.undoOperation != nil {
      if self.undoOperation.finished {
        let board = self.boardNode.board
        let turnPlayer = (board.turn == .Bad) ? self.badPlayer : self.goodPlayer
        if board.move > 0 && turnPlayer.isCom {
          // turn player is not human,
          // so undo again.
          self.undoOperation = NSBlockOperation {
            self.boardNode.undo()
          }
          self.queue.addOperation(self.undoOperation)
          return
        } else {
          // undo has been finished.
          self.undoOperation = nil
          
          if let lastAction = self.boardNode.lastAction {
            switch lastAction {
            case let .Play(row, col):
              self.boardNode.lightUpsquare(row, col, withOpponent: true)
            case let .Change(row, col):
              self.boardNode.lightUpsquare(row, col, withOpponent: true)
            default:
              break
            }
          }
        }
      } else {
        // do nothing,
        // because undo has been requested and not been finished.
        return
      }
    }
    
    self.selectOperation = NSBlockOperation {
      self.requestAction()
    }
    self.queue.addOperation(self.selectOperation)
  }
  
  public func boardNodeSquareIsSelected(node: BoardNode, _ square: SquareNode) {
    // do nothing
  }
}

この通知がくるのは、2つの場合が考えられる。
すなわち、play/change/passが行われた場合と、undoが行われた場合。

undoが行われた場合、手番が人の手番になっているかどうかを確認している。
人の手番になっていればundoは終わったので、通常のアクションの選択と実行へ移る。
そうでない場合、さらにアクションの巻き戻しを行って、人の手番になるようにする。

play/change/passが行われた場合は、基本的に次のアクションの選択と実行をすればいいのだけど、ただ、ここで一つ注意がいる。
それは、undoOperationがnilでなく、かつ終了していないという場合。
どうしたらそんなことが起こるのかというと、アクションの選択をする→ボードでアクションがキューに入れられる→Undoボタンが押される→ボードでUndoがキューに入れられる→ボードでアクションが行われて通知が来る、というタイミングが考えられるから。
この場合、Undoのリクエストも処理されてから次のアクションの選択に移らないといけないので、何もせずに戻る必要がある。
(そして、Undoが行われてから再び通知がくるので、そこで適切な処理を行う)

Undoの動作確認

さて、コントローラをちょっと修正して、動作確認。

//==============================
// YWF
//------------------------------
// GameViewController.swift
//==============================

import UIKit
import SpriteKit

class GameViewController: UIViewController {
  @IBOutlet weak var skView: SKView!
  
  override func viewDidLoad() {
    BoardNode.loadAssets()
    ButtonNode.loadAssets()
    PieceNode.loadAssetsAndCreateTemplates()
  }
  
  override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    
    var size = self.view.bounds.size
    if UIDevice.currentDevice().userInterfaceIdiom == .Phone {
      size.width *= 2.0
      size.height *= 2.0
    }
  
    let scene = GameScene(size: size)
    scene.scaleMode = .AspectFill
    
    let layoutManager = LayoutManager.getInstanceFor(size)
    
    let backgroundTexture = SKTexture(imageNamed: "Background")
    let background = SKSpriteNode(texture: backgroundTexture)
    background.position = layoutManager.getPosition(.Background, relativeTo: .Scene)
    scene.addChild(background)
    
    let headerPosition = layoutManager.getPosition(.Header, relativeTo: .Background)
    if headerPosition != nil {
      let headerTexture = SKTexture(imageNamed: "Header")
      let header = SKSpriteNode(texture: headerTexture)
      header.position = headerPosition
      background.addChild(header)
      
      let logoTexture = SKTexture(imageNamed: "Logo")
      let logo = SKSpriteNode(texture: logoTexture)
      logo.position = layoutManager.getPosition(.Logo, relativeTo: .Header)
      header.addChild(logo)
    }

    var board = Board()
    let boardNode = BoardNode(board: board)
    boardNode.position = layoutManager.getPosition(.Board, relativeTo: .Background)
    background.addChild(boardNode)
    
    let undoButton = ButtonNode(type: .Undo)
    let passButton = ButtonNode(type: .Pass, enabled: false)
    let exitButton = ButtonNode(type: .Exit)
    undoButton.position = layoutManager.getPosition(.UndoButton, relativeTo: .Background)
    passButton.position = layoutManager.getPosition(.PassButton, relativeTo: .Background)
    exitButton.position = layoutManager.getPosition(.ExitButton, relativeTo: .Background)
    background.addChild(undoButton)
    background.addChild(passButton)
    background.addChild(exitButton)
    
    self.skView.presentScene(scene)
    
    let badPlayer = Human(boardNode: boardNode, passButton: passButton)
    let goodPlayer = AlphaBetaCom(status: .Good, depth: 3)
    let turnController = TurnController(boardNode: boardNode, undoButton: undoButton,
                                        badPlayer: badPlayer, goodPlayer: goodPlayer)
    turnController.start()
  }
  
  // hide status bar.
  override func prefersStatusBarHidden() -> Bool {
    return true
  }
}

これを実行すると、Undoがちゃんと出来てるのが分かると思う。

今日はここまで!