Tic Tac Toe 實做

原來以為圈圈叉叉是很簡單的遊戲,設計成app應該不會太難。結果聽完了練功坊的介紹才知道才不是我想的這麼簡單,畢竟是人家芝加哥大學資工系的作業啊!

九個方格view加二個label組合的九宮格遊戲寫來為什麼這麼難呢? 一起來照著Peter範例寫的步驟一一破解一下:

1. 運用了 UIBezierPath()畫井字四條線

平常我們都是網路上直接抓一張圖,拉一下Auto Layout就可以做了,這裡要求用程式畫出來。於是要自訂UI的元件類別,定義它的function draw.

1–1. 設定GridView class為GridView.swift

因為這裡 demo的是iPhone 8 points 是375*667,設定方格為110*110,間隔為12.5, 起始為(x: 10, y:100)

因為我只會土法鍊鋼,所以手動去計算出來每個方格的x, y 位置, 這樣會對對應長寬加上間隔怎麼找到正確x,y的位置會更有感覺,而且容易理解 UIBezierPath()要表達的部份。另外一個原因是我手動拉 view會一直跑到 Grid View子階)

例如粉紅方格的x:255= 是10+(110+12.5)*2

藍色方格的y: 345 = 100 + (110+12.5)*2

1–2. GridView畫線時以長方形(Rect)的寬高為單位並且考慮線寬。設定x, y 軸時,x軸從0開始, y 軸為正方形的寬加線寬。

class GridView: UIView {override func draw(_ rect: CGRect) {let path = UIBezierPath()let squareWidth: CGFloat = 110let lineWidth: CGFloat = 12.5var y = squareWidth + lineWidth / 2path.move(to: CGPoint(x: 0, y: y))path.addLine(to: CGPoint(x: rect.width, y: y))y += squareWidth + lineWidthpath.move(to: CGPoint(x: 0, y: y))path.addLine(to: CGPoint(x: rect.width, y: y))var x = squareWidth + lineWidth / 2path.move(to: CGPoint(x: x, y: 0))path.addLine(to: CGPoint(x:x, y:rect.height))x += squareWidth + lineWidthpath.move(to: CGPoint(x:x, y: 0))path.addLine(to: CGPoint(x:x, y:rect.height))path.lineWidth = lineWidthUIColor.gray.setStroke()path.stroke()}}

要設定custom color 記得設setStroke()

2. 利用Info View顯示遊戲說明與結果

2–1. 設定Info View class為InfoView.swift

這裡可以看到Info view的位置被設定到了模擬器的千里之外,主要應該是希望viewDidLoad時不要看到,只有Button被啟動時才跑進來

2–2. Info View.swift裡 awakeFromNib設定Info View的圓角、邊框與顏色:

override func awakeFromNib() {super.awakeFromNib()layer.cornerRadius = 10layer.borderWidth = 5layer.borderColor = UIColor.white.cgColor}

2–3. 在Info View.swift裡拉text label 的IBOutlet。不過這裡有Peter有說,因為swift本身的關係,所以要手key字然後在利用旁邊的圓圈拉過去IBOutlet

2–4.設定出現動畫show() function

動畫執行bringSubviewToFront()確保Info View一定在最前面

func show(text:String){textLabel.text = textsuperview?.bringSubviewToFront(self)let animator = UIViewPropertyAnimator(duration: 1, curve: .easeIn){self.center = self.superview!.center}animator.startAnimation()}

2–5. 設定關閉動畫close() function

func close(){let animator = UIViewPropertyAnimator(duration: 1, curve: .easeIn){self.frame.origin.y = self.superview!.frame.maxY}animator.addCompletion {(_) inself.frame.origin.y = -self.frame.height}animator.startAnimation()}

3. Grid.swift判斷勝負

使用enum Piece 判斷方格為圈或叉,變數squares前加了lazy, 讓它只有在被使用時才會被載入。

設方格數常數為9,可連成一條線的組合設常數lines的陣列加字典。

enum Piece {case ocase x}let squareCount = 9lazy var squares = [Piece?](repeating: nil, count: squareCount)let lines = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]]

因為條件滿足為因為不曉得winner是誰,這裡設了一個for …in 的迴圈,加上allSatisfy(_:),取出squares[line[0]]的其中一條滿足條件,先滿足的為winner.

var winner: Piece? {var winner: Piece?for line in lines {if let firstPiece = squares[line[0]] {let result = line.allSatisfy{(index) -> Bool inreturn squares[index] == firstPiece}if result {winner = firstPiecebreak}}}return winner}

設定以下function:

9格全滿(isFull)、平手(isTie)、判斷某一格有沒有東西(isSquareEmpty)、在格子上放圈叉(occupy)與清除結果(clear)

var isFull: Bool {//indices為array的數字編號return squares.indices.allSatisfy { (index) -> Bool inreturn isSquareEmpty(index: index) == false}}var isTie: Bool {if isFull, winner == nil {return true} else {return false}}func isSquareEmpty(index: Int) -> Bool {return squares[index] == nil}func occupy(piece: Piece, on index: Int) {squares[index] = piece}func clear() {squares = [Piece?](repeating: nil, count: squareCount)}}

4. View Controller元件與Function

除了圈叉label, 九宮格view 連Outlet Collection(view連的順序會影響),因為需要手勢觸發,加入gestureRecognizer,在controller 裡會觸發infoView, 所以也需要手key加入。

加入圈叉label 與Pan Gesture Recognizer

因為到時候圈叉 label要fit方格比對,所以一樣長寬設110,User Interaction Enabled 設為false,然後加入Pan Gesture Recognizer.

GestureRecognizer拉IBAction使用 UIPanGestureRecognizer()移動label

然後裡面設計的 function 非常多…(真是魔鬼都在細節裡)

  1. takeTurn() 輪流的判斷與動畫
  2. labelLoadImage() l讓abel產生圖片
  3. newGame() 開始一回新遊戲
  4. createPieceLabel()產生新的label
  5. finishCurrentTurn()判斷一回合的遊戲結束
  6. placePiece()圈叉拖放至放方格動畫
  7. pieceBackToStartLocation()方格回到原點動畫
  8. movePiece() 移動方格
  9. Info View遊戲說明與關閉說明重新開始

說明如下:

4-1. takeTurn() 輪流的判斷與動畫

一開始載入怎麼樣知道輪到誰? UIViewPropertyAnimator的EaseIn&EaseOut載入Label 的Alpha值設透明度與往右邊擺動角度90度(pi / 4)的旋轉動畫判斷,並且設成default, 遊戲從圈開始,要輪到的人才能拖曳label.

func takeTurn(label: UILabel){let rotateAnimator = UIViewPropertyAnimator(duration: 2, curve: .easeIn){label.transform = CGAffineTransform(rotationAngle:CGFloat.pi / 4)label.alpha = 0.5}let backAnimator = UIViewPropertyAnimator(duration: 2, curve: .easeOut){label.transform = CGAffineTransform.identitylabel.alpha = 1}backAnimator.addCompletion { (_) inlabel.isUserInteractionEnabled = trueself.view.bringSubviewToFront(label)}rotateAnimator.addCompletion{ (_) in backAnimator.startAnimation()}}override func viewDidAppear(_ animated: Bool) {super.viewDidAppear(animated)takeTurn(label: oLabel)}override func viewDidAppear(_ animated: Bool) {super.viewDidAppear(animated)takeTurn(label: oLabel)}

4–2.labelLoadImage() 讓abel產生圖片

這裡把它寫成function, 因為除了一開始viewDidLoad畫面載入時需要之後,遊戲開始也需要,重新新一回合開始的遊戲也需要。

這裡用到的是Peter教過的利用 NSTextAttachment製作包含圖片的字串,因為我只要圖片,所以就把字串設為空。

func labelLoadImage(){let content = NSMutableAttributedString(string: "")let oAttachment = NSTextAttachment()oAttachment.image = UIImage(named: "o")oAttachment.bounds = CGRect(x: 255, y: 550, width: 90, height: 100)content.append(NSAttributedString(attachment: oAttachment))let Label = UILabel(frame: CGRect(x: 255, y:550, width: 110, height:110))let contentX = NSMutableAttributedString(string: "")let xAttachment = NSTextAttachment()xAttachment.image = UIImage(named: "x")xAttachment.bounds = CGRect(x: 10, y: 550, width:90, height:100)contentX.append(NSAttributedString(attachment: xAttachment))oLabel.numberOfLines = 20xLabel.numberOfLines = 20oLabel.attributedText = contentxLabel.attributedText = contentXview.addSubview(oLabel)view.addSubview(xLabel)}

這裡之所以要label賦圖是因為我把圈叉改成了我家父女讓他們可以pk

4–3. newGame() 開始一回新遊戲

func newGame() {grid.clear()labelLoadImage()UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 2, delay: 1, options: [], animations: {self.occupyPieces.forEach { (piece) inpiece.alpha = 0}}) { (_) inself.occupyPieces.forEach { (piece) inpiece.removeFromSuperview()}self.occupyPieces.removeAll()self.takeTurn(label: self.oLabel)}}

4–4. createPieceLabel()產生新的label

//產生新的labelfunc createPieceLabel(label:UILabel) -> UILabel {let newLabel = UILabel(frame: label.frame)newLabel.text = label.textnewLabel.font = label.fontnewLabel.backgroundColor = label.backgroundColornewLabel.textColor = label.textColornewLabel.textAlignment = label.textAlignmentnewLabel.alpha = 0.5newLabel.isUserInteractionEnabled = falsereturn newLabel

4–5 finishCurrentTurn()判斷一回合的遊戲結束

func finishCurrentTurn(label: UILabel, index: Int, originalPieceCenter: CGPoint) {occupyPieces.append(label)let newLabel = createPieceLabel(label: label)newLabel.center = originalPieceCenterview.addSubview(newLabel)let nextLabel: UILabelif label == xLabel {self.grid.occupy(piece: .x, on: index)xLabel = newLabelnextLabel = oLabel} else {self.grid.occupy(piece: .o, on: index)oLabel = newLabelnextLabel = xLabel}if let winner = grid.winner {if winner == Grid.Piece.o {infoView.show(text: "Congratulations, O wins!")} else {infoView.show(text: "Congratulations, X wins!")}} else if grid.isTie {infoView.show(text: "Tie")} else {takeTurn(label: nextLabel)}}

4–6. placePiece()圈叉拖放至放方格動畫

func placePiece(_label: UILabel, on square: UIView, index: Int) {var originalPieceCenter = CGPoint.zeroUIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0, options: [], animations: {_label.transform = .identityoriginalPieceCenter = _label.center_label.center = square.center}) {(_) inself.finishCurrentTurn(label: _label, index: index, originalPieceCenter: originalPieceCenter)}}

4–7. pieceBackToStartLocation()方格回到原點動畫

func pieceBackToStartLocation(label: UILabel){UIView.animate(withDuration: 0.5) {label.transform = .identity}}

4–8. movePiece() 移動方格

因為要實現手放開時,如果放在格子以外或是格子已滿時,圈叉會回到原點(.identity) ,調整transform 移動圈叉,square判斷拖曳圈叉時,會依照label與方格的交會面積(intersects)判斷停留在哪一格。

@IBAction func movePiece(_ sender: UIPanGestureRecognizer) {guard let label = sender.view as? UILabel else {return}if sender.state == .ended {var maxIntersctionArea: CGFloat = 0var targetSquare: UIView?var targetIndex: Int?for (i, square) in squares.enumerated() {let intersectionFrame =square.frame.intersection(label.frame)let area = intersectionFrame.width * intersectionFrame.heightif area > maxIntersctionArea {maxIntersctionArea = areatargetSquare = squaretargetIndex = i}}if let targetSquare = targetSquare, let targetIndex = targetIndex, grid.isSquareEmpty(index: targetIndex) {placePiece(_label: label, on: targetSquare, index: targetIndex)} else {pieceBackToStartLocation(label: label)}} else {let translation = sender.translation(in: view)label.transform = CGAffineTransform(translationX: translation.x, y: translation.y)}labelLoadImage()}

4–9.說明遊戲結果

因為要在主畫面顯示Info View的說明與結果,所以在View Controller將Info View的 ok Button 連IBOutlet and IBAction,一樣是手key然後連過去。

@IBAction func infoButtonTapped(_ sender: Any){infoView.show(text: "Get 3 in a row to win!")}

關閉Info View重新開始

@IBAction func closeInfoView(_ sender: Any) {infoView.close()if grid.winner != nil || grid.isTie {newGame()}}

我覺得這每個功能根本都可以單獨拉出來做app練習, combine的難度真的很高。

最後,我認為即便是幼兒園的小朋友來玩,也不會有連二條線的事發生,他們雖然幼稚尚未發展完全,但不是白痴。

Stay tic-tac-toe Github!

--

--