(Swift) 使用 MVC 架構製作翻牌配對遊戲

新增一個 button 到 storyboard 中,並設定 background layer.cornerRadius

點選左下角的 + ,在 Key Path -> layer.cornerRadius Type -> Number Value -> 15

一次複製 3 個 UIButton,並用 Stack View 將其包住,並再次複製 3個 Stack View,最後再使用 Satck View 將其包住

設定最外層 Stack View AutoLayout

設定左上角的 button height -> 120

最外層 Stack View Spacing -> 10

新增 Outlet Collection cardsButton

新增 @IBAction func touchCard

新增 Swift File Card,並輸入以下程式碼到其中

import Foundation

struct Card {
// 判斷卡牌是否翻面
var isFaceUp: Bool = false
// 判斷卡牌是否配對
var isMatched: Bool = false
// 每張卡牌的 id
var identifier: Int


// 每產生一張卡牌就產生 i+1 的 id 給它,從 1 開始產生
static var identifierFactory = 0

static func getUniqueIdentifier() -> Int {
identifierFactory += 1
return identifierFactory
}

init() {
self.identifier = Card.getUniqueIdentifier()
}
}

每產生一次 Card 就給定一個 id = 1,產生第二張卡牌的時候,就給定 id = 2 ,以此類推

新增 Swift File MatchingGame,並輸入以下程式碼到其中

import Foundation


class MatchingGame {

// 保存產生的每張 卡牌
var cards: Array<Card> = Array()


// 若有有 4 個的 emoji ,就會有 8 個 id
init(numberOfPairsOfCards: Int) {
for _ in 1...numberOfPairsOfCards {
// 一次產生兩張卡牌
let card = Card()
// 更加有效率的寫法
cards += [card, card]

}

}

}

在初始化時,會傳入要產生的卡牌數量及 id 數量,由於卡牌是兩兩成對的,所以在每一個迴圈,會產生兩張同樣 id 的卡牌

在 ViewController 新增以下變數

import UIKit

class ViewController: UIViewController {


@IBOutlet var cardsButton: [UIButton]!


// +1 是為了確保牌數
// lazy 等元件都好了之後,再去初始化
// 使用 lazy 的話,didSet 就無法使用
lazy var game: MatchingGame = MatchingGame(numberOfPairsOfCards: (cardsButton.count+1) / 2)

為了產生對應的 UIButton 數量,我們直接丟入 cardsButton.count ,但是在產生最初畫面時,UIButton 是還沒有完成初始化的,這時候就必須在 var game 前面加入 lazy

在 ViewController 中,新增保存 emoji 的字典以及顯示的 emoji 陣列

import UIKit

class ViewController: UIViewController {


...

// emoji 保存 每個卡牌的 id 與 顯示符號
var emoji = Dictionary<Int,String>()
var emojiChooices = ["😁", "💀", "👻", "🐶", "🐳", "🐢", "🐭", "🐵"]
let copyEmoji = ["😁", "💀", "👻", "🐶", "🐳", "🐢", "🐭", "🐵"]

新增回傳卡牌文字的 function getEmoji

    // 回傳表情符號
func getEmoji(for card: Card)->String{
if emoji[card.identifier] == nil, emojiChooices.count>0{
// random 一個數值
let randomIndex = Int(arc4random_uniform(UInt32(emojiChooices.count)))
// 從 emojiChooices 中隨機加入一個 emoji 後,使用 rmove 移除加入的 emoji
emoji[card.identifier] = emojiChooices.remove(at: randomIndex)
}
return emoji[card.identifier] ?? "?"
}

每次呼叫 getEmoji 時,就會回傳一個 emojiChooices 中選擇的 emoji,並且移除該選到的 emoji

新增卡牌顯示文字樣式的 function updateFont updateViewFromModel

import UIKit

class ViewController: UIViewController {


...

// 更新 button NSAttributedString
func updateFont(sender: UIButton, string: String) {
let font = UIFont.systemFont(ofSize: 50)
let attributes = [NSAttributedString.Key.font: font]
let message = NSAttributedString(string: string, attributes: attributes)
sender.setAttributedTitle(message, for: .normal)
}

// 更新卡牌畫面
func updateViewFromModel() {
// 判斷所有的 cardsButton
for index in cardsButton.indices {
let button = cardsButton[index]
let card = game.cards[index]

// 判斷卡是否配對到了
if !card.isMatched { //isMatched = false
// 還沒配對就執行以下程式
if !card.isFaceUp {//
updateFont(sender: button, string: "")
button.backgroundColor = #colorLiteral(red: 0.2509803922, green: 0.3176470588, blue: 0.231372549, alpha: 1)
}else {
updateFont(sender: button, string: getEmoji(for: card))
button.backgroundColor = #colorLiteral(red: 0.9432101846, green: 0.953361094, blue: 0.8697650433, alpha: 1)
}
}else {
updateFont(sender: button, string: getEmoji(for: card))
button.backgroundColor = #colorLiteral(red: 0.2549019754, green: 0.2745098174, blue: 0.3019607961, alpha: 1)
// 確保 flipcount 不再更新,將 isEnabled 關閉
button.isEnabled = false
}

}
}

MatchingGame 新增 function chooseCard var indexOfOneAndOnlyFaceUpCard flipcount

import Foundation


class MatchingGame {

// 保存產生的每張 卡牌
var cards: Array<Card> = Array()

// 紀錄翻牌次數
var flipcount = 0 {
didSet {
flipcount += 1
}
}


// 紀錄第一張被翻開的卡牌 id
var indexOfOneAndOnlyFaceUpCard: Int? {
get {
var foundIndex: Int?
for index in cards.indices {
// 給定翻開對應的卡牌 id 給 indexOfOneAndOnlyFaceUpCard
// 也就是第一次點選時,被翻開的卡片 id,翻開的 卡牌 id 就會給 foundIndex
if cards[index].isFaceUp {
if foundIndex == nil {
foundIndex = index
// 在最初始遊戲時,點選第一張卡牌都會回傳 nil
}else {
return nil
}
}
}
return foundIndex
}set {
// 給定 indexOfOneAndOnlyFaceUpCard = index 時,會觸發以下 迴圈
// 這時會將一開始翻面的卡牌的 isFaceUp 設為 true
// 因為 index 會跑完所有 cards ,而 只會有一個 index 會跟 newValue 值相同
// 當有兩張卡牌已被翻面時,點選第三張,會將唯一的那張翻面卡牌設定翻面,其它牌都設為反面
for index in cards.indices {
cards[index].isFaceUp = (index == newValue)

}

}
}


func chooseCard(at index: Int)->Card{
// 當點選到的卡牌沒有被配對時,會執行以下判斷式
if !cards[index].isMatched{// isMatched = false
// matchIndex 會是第一張被翻開的卡牌的 id
// matchIndex != index 確保不為同張卡牌
// 當有第一張卡牌翻開時點選第二張卡,才會執行以下判斷式
if let matchIndex = indexOfOneAndOnlyFaceUpCard, matchIndex != index{
// 翻開第二張卡牌若是相同的卡牌,就執行以下判斷式
// 將配對成功的卡牌的 isMatched 設為 true
if cards[matchIndex].identifier == cards[index].identifier{
cards[matchIndex].isMatched = true
cards[index].isMatched = true
}
// 將點選到的卡牌 isFaceUp 設為 true
cards[index].isFaceUp = true
}
// 沒有卡片被翻開,或是兩張卡牌被翻開並選第三張卡牌,執行以下判斷式
else{
// 若是已經翻開的卡牌,再次點選將會把卡牌翻面
if cards[index].isFaceUp == true{
cards[index].isFaceUp = false
}else{
// 每次點擊第一張卡牌,會先將畫面中 UIButton 的 index 賦予 indexOfOneAndOnlyFaceUpCard 數值
indexOfOneAndOnlyFaceUpCard = index
}
}
}

return cards[index]
}

// 在初始化時,會傳入要產生的卡牌數量及 id 數量,由於卡牌是兩兩成對的,所以在每一個迴圈,會產生兩張同樣 id 的卡牌
// 若有有 4 個的 emoji ,就會有 8 個 id
init(numberOfPairsOfCards: Int) {

for _ in 1...numberOfPairsOfCards {
// 一次產生兩張卡牌
let card = Card()
// 更加有效率的寫法
cards += [card, card]

}

}

}

@IBAction func touchCard 中新增以下程式碼

    
@IBAction func touchCard(_ sender: UIButton) {
// 抓取點選的 button id
// 將抓取到的 id 傳送到 game.chooseCard(at: cardNumber) 中
if let cardNumber = cardsButton.firstIndex(of: sender){
let _ = game.chooseCard(at: cardNumber)
// 更新卡牌畫面
updateViewFromModel()

// 增加翻牌次數
game.flipcount += 1

}

}

執行程式看看

此時卡牌遊戲會滿足以下功能

  1. 點選第一張卡牌時,會出現 emoji
  2. 點選第二張卡牌,若是相同 emoji ,兩張卡牌就會變成灰色,且不能點選
  3. 若是點選第二張卡牌沒有配對,點選第三張卡牌,將會關閉前兩張卡牌

打亂畫面中的卡牌

只需要在 viewDidLoadcardsButton shuffle 就可完成


import UIKit

class ViewController: UIViewController {

...

override func viewDidLoad() {
super.viewDidLoad()

// 打亂卡片排列
cardsButton.shuffle()

}

製作計數的 UILabel 翻開全部牌的 UIButton 重新開始的 UIButton

最後再使用 Stack View 將其包住

設定 Stack View 的 AutoLayout

設定 UILabel 的 Content Hugging Priority Horizontal 設定為 249

拉 Flip All 的 @IBAction func check,並輸入以下程式碼

    @IBAction func check(_ sender: UIButton) {
// 翻開所有 卡牌
// 並更新每張卡牌的狀態
for i in game.cards.indices {
game.cards[i].isFaceUp = true
game.cards[i].isMatched = true
}

updateViewFromModel()
// 將翻牌次數歸 0
game.flipcount = 0

}

新增 @IBOutlet weak var flipcountLable

import UIKit

class ViewController: UIViewController {


...
@IBOutlet weak var flipcountLable: UIStackView!

新增 private func updateFlipCountLabel

    // 更新 label NSAttributedString
private func updateFlipCountLabel() {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 30),
.strokeColor: UIColor.darkGray,
// 裡面填滿要寫 -1, 3 會是空心的
.strokeWidth: -1,
.foregroundColor: UIColor.darkGray

]

let attribtext = NSAttributedString(string: "Flips: \(game.flipcount / 2)", attributes: attributes)
flipcountLable.attributedText = attribtext

}

在 function check 新增以下 updateFlipCountLabel()

    @IBAction func check(_ sender: UIButton) {
// 翻開所有 卡牌
// 並更新每張卡牌的狀態
for i in game.cards.indices {
game.cards[i].isFaceUp = true
game.cards[i].isMatched = true
}

updateViewFromModel()
// 將翻牌次數歸 0
game.flipcount = 0
// 更新 flipcountLable
updateFlipCountLabel()

}

在 function touchCard 新增以下 updateFlipCountLabel()

    @IBAction func touchCard(_ sender: UIButton) {
// 抓取點選的 button id
// 將抓取到的 id 傳送到 game.chooseCard(at: cardNumber) 中
if let cardNumber = cardsButton.firstIndex(of: sender){
let _ = game.chooseCard(at: cardNumber)
// 更新卡牌畫面
updateViewFromModel()

// 增加翻牌次數
game.flipcount += 1
// 更新 flipcountLable
updateFlipCountLabel()

}

}

新增 @IBAction func ResetButton

    @IBAction func ResetButton(_ sender: UIButton) {

// 設定所有卡牌 isFaceUp isMatched 為 fasle
for i in game.cards.indices {
game.cards[i].isFaceUp = false
game.cards[i].isMatched = false
}
// 讓所有 button 都能點選
for i in cardsButton.indices {
cardsButton[i].isEnabled = true
}
// 更新卡牌畫面
updateViewFromModel()
// flipcount 歸 0
game.flipcount = 0
// 再次填充 emojiChooices
emojiChooices = copyEmoji
// 再次 shuffle emojiChooices
emojiChooices.shuffle()
// 再次 shuffle cardsButton
cardsButton.shuffle()
// 移除舊有的 emoji
emoji.removeAll()
// 更新 flipcountLable
updateFlipCountLabel()

}

最後執行程式應該就能完成本次的遊戲

最後增加翻牌特效

在 function touchCard 新增以下程式碼

    @IBAction func touchCard(_ sender: UIButton) {
// 抓取點選的 button id
// 將抓取到的 id 傳送到 game.chooseCard(at: cardNumber) 中
if let cardNumber = cardsButton.firstIndex(of: sender){
let _ = game.chooseCard(at: cardNumber)

// 增加翻牌效果
UIView.transition(with: cardsButton[cardNumber], duration: 0.3, options: .transitionFlipFromLeft, animations: nil, completion: nil)
...

}

}

GitHub

--

--