寫一個猜燈謎的小遊戲

Una
彼得潘的 Swift iOS / Flutter App 開發教室
12 min readAug 5, 2023

剛要練習這個主題,立馬想到知識王LIVE,記得以前很愛跟朋友玩知識王LIVE,但我們不是一起跟人PK,是一起回答同一個問題去打敵人(☝ ՞ਊ ՞)☝嘿嘿,但又看到他出了另一個遊戲APP,表情符號王,立馬下載,原本要做作業,結果自己玩了1個小時…超好玩

所以!決定自己來做看看~Let’s Go!

學習目標

  • for 迴圈
  • if else
  • array
  • 亂數
  • 自訂資料型別
  • MVC分類檔案
  • 專案資料
  • computed property
  • property observer
  • 頁面之間傳遞資料

最終成果

遊戲參考資料:

📍MVC分類檔案:

在這次專案中我將資料分為Models及View Controllers進行管理。

  • Models: 我就會放自己定義的資料型別
  • View Controllers: 我就會放每個畫面的程式碼

📍創建型別:

這個遊戲需要很多題目,於是在models裡建立一個Question的型別,之後好用來生成一個題庫的陣列。在這個類別中需要三個參數分別為:題目,答案,魚目混珠的文字。

題目,答案,魚目混珠的文字

在這裡面我希望生成10個隨機的中文字,因此我需要先用一個generateRandomChineseCharacters的方法隨機創建10個文字

func generateRandomChineseCharacters(count: Int) -> [String] {
var result: [String] = []

for _ in 0..<count {
// 使用 arc4random_uniform 函數生成一個介於 0x3400 和 0x4E00 - 1 之間的隨機數。這些數值範圍包含了中文字符的 Unicode 範圍。
let randomUnicodeValue = arc4random_uniform(UInt32(0x4E00 - 0x3400)) + UInt32(0x3400)

if let scalar = Unicode.Scalar(randomUnicodeValue) {
let character = Character(scalar)
result.append(String(character))
}
}

return result
}

創建完10個隨機文字後,需要將真正的答案加入隨機文字陣列中,但只加在後面當然不行,因此需要使用shuffle()來讓陣列裡面的元素隨機排列。

func getRandomChineseCharacters(anser: [String]) -> [String] {
// 加入真正的答案,兩個變數的類型都為[String]因此可以直接用加號
var allCharacters = generateRandomChineseCharacters(count: 10) + anser
// 陣列裡面的元素隨機排列
allCharacters.shuffle()
return allCharacters
}

在GameViewController使用:

var gameDatas: [Question] = [
Question(topic: ["🐯", "🐯", "🐯", "🐯"], ans: ["虎", "頭", "虎", "腦"]),
Question(topic: ["👣", "📈", "💨", "🤷‍♂️"], ans: ["趾", "高", "氣", "揚"]),
Question(topic: ["😈", "💬", "💔", "👤"], ans: ["惡", "語", "傷", "人"]),
Question(topic: ["1️⃣", "🎵", "🤯", "👤"], ans: ["一", "鳴", "驚", "人"]),
// ...省略其他
]

📍關卡更新:

在初始時就會生成第一關所以當一開始所有gameDatas都準備好的時候,每次都會進到updateLevel()拿新的一關,並且移除掉gameDatas已經玩過的題目,直到gameDatas沒有資料,遊戲結束。

func updateLevel() {
// 判斷還有沒有題目
if !gameDatas.isEmpty {
// 用過的題目就移除
levelData = gameDatas.removeFirst()
if let question = levelData {
// 更新題目標籤
for (index, label) in topicLabel.enumerated() {
if index < question.topic.count {
label.text = question.topic[index]
}
}
// 更新按鈕文字
for (index, button) in wordsButton.enumerated() {
if index < question.others.count {
button.setTitle(question.others[index], for: .normal)
}
}
}
} else {
gameOverView.isHidden = false
}
}

📍點擊文字按鈕:

因為我要做動畫,所以必須記錄按鈕要飛到哪。可以打開stoybroad查看答案方框的位置在哪。

查看方框位置在哪裡

點擊第一次位置是1點;擊點第二次位置是2以此類推,找出每個位置,寫switch做判斷。

let targetY: CGFloat = 425
var targetX: CGFloat

clickAnsCount += 1
switch clickAnsCount {
case 1:
targetX = 94

case 2:
targetX = 155

case 3:
targetX = 215

case 4:
targetX = 276

default:
targetX = 94
}

設定好每次需要移動到的位置後,可以用animate來設定需要移動到的CGPoint,並且移動後的按鈕都不能再點擊。

// 移動按鈕位置並禁用
UIView.animate(withDuration: 0.5) {
sender.frame.origin = CGPoint(x: targetX, y: targetY)
sender.isEnabled = false
sender.backgroundColor = .white
sender.layer.cornerRadius = 5
sender.setTitleColor(.darkGray, for: .disabled)
}

另外因為動畫會需要時間跑所以如果沒有延遲更新下一關的話,第四顆鈕就會跑不出來,直接到下一關,我們必須設定類似setTimeout的佇列才能避免這個狀況。

不等第四顆按鈕出現就跑下一關了
let delayInSeconds: Double = 1.0 // 延遲 1 秒
// 延遲一段時間後執行的程式碼
DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds) { [self] in
// 在這裡放置延遲執行的程式碼
if self.isGameOver {
self.gameOverView.isHidden = false
} else {
self.initUI()
self.updateLevel()
}
}
等待一秒後才換下一關

應該有發現在下一關的時候需要讓按鈕都乖乖回去,所以必須在viewDidLoad()的時候就記下所有按鈕的位置,在更新關卡的時候才能讓按鈕乖乖回去。

override func viewDidLoad() {
super.viewDidLoad()
setOriginalButtonPosition()
}

// 設定按鈕的初始位置
func setOriginalButtonPosition() {
for word in wordsButton {
originalButtonPosition.append(word.frame.origin)
}
}

// 初始化界面元素
func initUI() {
for (index, word) in wordsButton.enumerated() {
// 更新關卡讓按鈕各自歸位,並且恢復可以點擊
UIView.animate(withDuration: 0.5) {
word.frame.origin = self.originalButtonPosition[index]
word.isEnabled = true
}
}
}

📍computed property:

// 判斷遊戲是否結束的計算屬性
var isGameOver: Bool {
return heartsImageView.isEmpty
}

📍property observer:

// 紀錄玩家獲勝次數,屬性觀察器會自動更新界面
var winAcount = 0 {
didSet {
winAcountLabel.text = String(winAcount)
}
}

📍頁面間的溝通:

需要使用到@IBSegueAction,產生這個方法的方式跟產生@IBAction一樣,只需要找到Segue然後按著control即可。特別注意:在結果頁,因為class裡面的參數是需要初始值,不然就是必須定義為optional,因此我確定我一定會傳資料過去,所以給“!”告訴他,我一定要取資料

// 在結果頁接收資料
class resultViewController: UIViewController {
// 我一定要取資料,強制取資料,但沒有的話就壞掉,特別注意
var heartsAcount: Int!

@IBOutlet weak var resultImageView: UIImageView!
@IBOutlet weak var resultLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
showResult()

}

func showResult() {
if heartsAcount > 0 {
resultImageView.image = UIImage(named: "win")
resultLabel.text = "LEVEL UP"
} else {
resultImageView.image = UIImage(named: "lose")
resultLabel.text = "You LOSE!"
}
}
}

附上github

--

--