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 非常多…(真是魔鬼都在細節裡)
- takeTurn() 輪流的判斷與動畫
- labelLoadImage() l讓abel產生圖片
- newGame() 開始一回新遊戲
- createPieceLabel()產生新的label
- finishCurrentTurn()判斷一回合的遊戲結束
- placePiece()圈叉拖放至放方格動畫
- pieceBackToStartLocation()方格回到原點動畫
- movePiece() 移動方格
- 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!