(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
}
}
執行程式看看
此時卡牌遊戲會滿足以下功能
- 點選第一張卡牌時,會出現 emoji
- 點選第二張卡牌,若是相同 emoji ,兩張卡牌就會變成灰色,且不能點選
- 若是點選第二張卡牌沒有配對,點選第三張卡牌,將會關閉前兩張卡牌
打亂畫面中的卡牌
只需要在 viewDidLoad
對 cardsButton
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)
...
}
}