⑤③點餅乾遊戲 Boxing Time

Outlet & Action、Segmented Control、動畫與音效。

Min
彼得潘的 Swift iOS / Flutter App 開發教室
29 min readSep 1, 2023

--

這是我的第二個 Outlet & Action App,第一個都是用 Slider 完成,點餅乾遊戲會用到更多內容,有點緊張啊!以下將分四個步驟進行,先完成畫面與點擊計數、再加入動畫、串接音效,最後設計 App 的啟動畫面。

畫面與點擊計數

準備圖片

先上網找圖片,這次 App 想試試看日系可愛的風格,在這個網站的”英語検索”裡,可以用英文搜尋到想要的素材:

將圖片放入 Assets 的同時,創建 App 的 icon:

stoyrboard 規劃外觀

先在 storyboard 放入 Image View、Label 與Button,注意想要點擊的區塊要放一個隱形的 Button。放入 Segmented Control 時也先改好頁籤的名稱:

在沙袋圖上面,放上 punchCountButton 以接收使用者的點擊

串接 IBOutlet 與 IBAction

在寫程式之前,不要忘記先把畫面上的元件創建到 View Controller 裏面。IBOutlet放到 viewDidLoad 之前,IBAction 放到 viewDidLoad 之後。

其中 Segmented Control 需要連接 IBAction 與 IBOutlet,才能在使用切換沙袋或垃圾桶的 function 的時候,看 Segmented Control 是點在沙袋還是垃圾桶。連接 IBAction 的時候,Type 要選擇 UISegmentedControl ,才能使用 selectedSegmentIndex 來接收目前是點到左邊還是右邊。

import UIKit

class ViewController: UIViewController {
// punchThingSegmentedControl 是上方的沙袋or垃圾桶選項
@IBOutlet weak var punchThingSegmentedControl: UISegmentedControl!
// 出拳次數下方的 Label,負責顯示統計點擊的數字
@IBOutlet weak var punchCountLabel: UILabel!
// 中央放沙袋與垃圾桶的 Image View
@IBOutlet weak var sandBagImageView: UIImageView!
// 右上角放女拳擊手跟男拳擊手的 Image View
@IBOutlet weak var boxerImageView: UIImageView!

// 預設 segmentedPage 在第一個頁籤,因為第一個的 index 是 0,所以 = 0 。
var segmentedPageIndex = 0
// 因為兩個物品的點擊數量要分開儲存,所以各設定一個變數。
var sandBagPunchCount = 0 // 沙袋點擊數
var trashCanPunchCount = 0 // 垃圾桶點擊數

//這裡是 View Controller 一開始必執行的地方,因為剛進入頁面的時候,還沒點擊 segmentedControl,所以沙袋頁沒辦法運作,點了也不會有反應。一定要看到沙袋的 Image View 與沙袋點擊數才會運作。
override func viewDidLoad() {
super.viewDidLoad()
// 顯示沙袋圖
sandBagImageView.image = UIImage(named: "boxing_sandbag")
}

// 在 segmentedControl 點選沙袋或垃圾桶的函數
@IBAction func segmentedControl(_ sender: UISegmentedControl) {
// selectedSegmentIndex 讀取我們點擊了 segmentedControl 中的第幾個頁籤
segmentedPageIndex = punchThingSegmentedControl.selectedSegmentIndex
//如果讀取第一個頁籤 沙袋
if segmentedPageIndex == 0{
// 呼叫女拳擊手圖片
boxerImageView.image = UIImage(named: "sports_boxing_woman")
// 正中央擺沙袋圖
sandBagImageView.image = UIImage(named: "boxing_sandbag")
// 出拳次數下方的數字使用沙袋被點擊的記數
punchCountLabel.text = "\(sandBagPunchCount)"
}else{ //不是點沙袋的話,就是垃圾桶
// 呼叫男拳擊手圖片
boxerImageView.image = UIImage(named: "sports_boxing_man")
// 正中央擺垃圾桶圖
sandBagImageView.image = UIImage(named: "gomi_poribaketsu_close")
// 出拳次數下方的數字使用沙袋被點擊的記數
punchCountLabel.text = "\(trashCanPunchCount)"
}

}

// 正中央隱形的 Button,被點擊後的動作
@IBAction func punchCountButton(_ sender: Any) {
// 如果被點擊的當下是沙袋圖,
if sandBagImageView.image == UIImage(named: "boxing_sandbag"){
// 則沙袋被點擊數量 + 1
sandBagPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(sandBagPunchCount)"
// 如果被點擊的當下是垃圾桶圖,
}else if sandBagImageView.image == UIImage(named: "gomi_poribaketsu_close"){
// 則垃圾桶被點擊數量 + 1
trashCanPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(trashCanPunchCount)"

}

}
// 點擊重新來過 Button 的動作
@IBAction func playAgainButton(_ sender: Any) {
// 如果被點擊的當下是沙袋圖,
if sandBagImageView.image == UIImage(named: "boxing_sandbag"){
// 則沙袋被點擊數量歸 0
sandBagPunchCount = 0
// 歸 0 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(sandBagPunchCount)"
// 如果被點擊的當下是垃圾桶圖,
}else if sandBagImageView.image == UIImage(named: "gomi_poribaketsu_close"){
// 則垃圾桶被點擊數量歸 0
trashCanPunchCount = 0
// 歸 0 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(trashCanPunchCount)"


}
}
}

在文組班學姊的文章中,提醒了 viewDidLoad 裏面必須放第一頁(沙袋)的畫面,不然可能會有問題:

我秉持著實驗的精神,不放沙袋圖進去,果然模擬器打開後,怎麼點沙袋都沒有反應。原來是因為剛進入 App,還沒點擊 Segmented Control,因此不會執行他的 function。那將沙袋圖放到 viewDidLoad 之後,後面的 function 因為都依賴沙袋圖判定執行條件,因此有了沙袋圖,我們點擊的時候就會開始紀錄數字。

第一階段成果:

點擊動畫

我想要有兩種動畫,第一個是在點擊沙袋或垃圾桶的時候,能夠在圖片旁出現 +1。第二個是點擊到 30 下以後,右上角的拳擊手會變成比較累的模樣。

+1 動畫

在 storyboard 裡加入 +1 圖片,串接到 viewDidLoad 中,先把 +1 圖片的 plusOneImageView 設為隱藏。

override func viewDidLoad() {
super.viewDidLoad()
// 顯示沙袋圖
sandBagImageView.image = UIImage(named: "boxing_sandbag")
// 新增程式
// +1 圖片先隱藏著
plusOneImageView.isHidden = true
}

然後到 punchCountButton 的函數裡,在if…else之前添加圖片動畫(因為不論是點沙袋還是垃圾桶,都要 +1)。

這邊向 AI 請教動畫的寫法。寫法有很多種,最後我選擇了不是最簡短,但比較好記的方式先寫。

後來發現彼得潘也是建議這個方式,而不是 AI 第一時間跟我講的 UIView.animate

在點擊 punchCountButton 區塊之後,顯示 +1 圖片。與此同時,創建一個費時 0.2 秒沒有變速的動畫,動畫本人 plusOneImageView 向上移動 5 px。因為動畫完成後要消失,所以加上 animator.addCompletion 指示動畫完後的處理方式。先隱藏起來,並且移回原位。少了移回原位的話,+1 就會一直往上跑,直到跑出手機畫面。

@IBAction func punchCountButton(_ sender: Any) {
// 新增程式
// 顯示 +1 圖片
plusOneImageView.isHidden = false

// 創建 UIViewPropertyAnimator
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .linear) {
// 在這裡設定 plusOneImageView 向上移動 5 點
self.plusOneImageView.frame.origin.y -= 5
}

// 動畫完成後的處理(這裡隱藏 plusOneImageView 並移回原始位置)
animator.addCompletion { (position) in
if position == .end {
self.plusOneImageView.isHidden = true
self.plusOneImageView.frame.origin.y += 5
}
}

// 開始動畫
animator.startAnimation()
// 新增至此,後面為 if...

+1 動畫成果:

你累了嗎?

再來是改變boxerImageView。點擊 30 次後,右上角拳擊手換圖片。這邊瘋狂使用 else if!邏輯是這樣的:

if 點擊沙袋圖,並且沙袋已被點擊 30 次 => 出現角落休息的女拳擊手

else if 點擊沙袋圖 => 出現女拳擊手

else if 點擊垃圾桶,並且垃圾桶已被點擊 30 次 => 出現角落休息的男拳擊手

else if 點擊垃圾桶 => 出現男拳擊手

@IBAction func punchCountButton(_ sender: Any) {
// 中間動畫省略

// 如果被點擊的當下是沙袋圖,並且沙袋已被點擊 30 次
if sandBagImageView.image == UIImage(named: "boxing_sandbag") && sandBagPunchCount >= 30{
// 則沙袋被點擊數量 + 1
sandBagPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(sandBagPunchCount)"
// 出現角落休息的女拳擊手
boxerImageView.image = UIImage(named: "sports_boxing_corner_woman")
}

// 如果被點擊的當下是沙袋圖,
else if sandBagImageView.image == UIImage(named: "boxing_sandbag"){
// 則沙袋被點擊數量 + 1
sandBagPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(sandBagPunchCount)"

// 如果被點擊的當下是垃圾桶圖,並且垃圾桶已被點擊 30 次
}else if sandBagImageView.image == UIImage(named: "gomi_poribaketsu_close") && trashCanPunchCount >= 30{
// 則垃圾桶被點擊數量 + 1
trashCanPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(trashCanPunchCount)"
// 出現角落休息的男拳擊手
boxerImageView.image = UIImage(named: "sports_boxing_corner_man")

// 如果被點擊的當下是垃圾桶圖,
}else if sandBagImageView.image == UIImage(named: "gomi_poribaketsu_close"){
// 則垃圾桶被點擊數量 + 1
trashCanPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(trashCanPunchCount)"
}

第二階段成果:

請看出拳次數>30的變化

串接音效

發現了一個不錯的音效網站:

https://pixabay.com/

裡面有各種圖片、動畫、影像、音樂、音效資源,輸入關鍵字後在 filter 點選 Sounds Effects:

出現蠻多製作良好的音效,因為我們的遊戲要一直點點點,音效時間太長都會被切斷,我找了些長度一秒內或寫 0 秒的音效下載。

還有像這種印第安那瓊斯風格的重擊聲XD

剛好不久前練習完放置音樂,先複習一下:

下載好音效後,將 mp3 檔案拖曳到 Navigator 裡面(不到 Assets 裡)。

左邊兩個播放圖案的檔案就是 mp3 了; 右邊在勾選的時候最上與最下行記得都要打勾

一開始把所有程式寫好後,開模擬器發現一個 bug,如果我只點一下,聲音可以完整出來。但如果快速的點,聲音會等到我完全停止才出來。應該是因為點的速度太快了,音效都還沒出來就被切斷要播放下一個點擊的音效。

把兩段音效放到 Logic Pro 裡,可以看到他們聲音出來之前都還有可以剪裁的空間:

都裁短:

改好後每一次點擊都有聲音了(雖然聲音因為還是播不完會變難聽,但至少有聲音了)。

影音相關程式:

// 添加音樂需要先導入負責影音的 AVFoundation
import AVFoundation


class ViewController: UIViewController {
// 增加 AVAudioPlayer 的 instance 負責播放音樂
var player = AVPlayer()
// 中間省略

// 在點擊 Button 的方法裡加入音樂設定
@IBAction func punchCountButton(_ sender: Any) {
// 取得沙袋音效檔案的 url,forResource 傳入檔案名稱,withExtension 傳入附檔名
let sandBagUrl = Bundle.main.url(forResource: "punchShort", withExtension: "mp3")!
// 創建 AVPlayerItem,使用 sandBagUrl 作為初始化參數,以準備播放 "punchShort.mp3"
let sandBagPlayerItem = AVPlayerItem(url: sandBagUrl)
// 取得垃圾桶音效檔案的 url
let trashCanUrl = Bundle.main.url(forResource: "hit-sometingShort", withExtension: "mp3")!
// 創建 AVPlayerItem,使用 trashCanUrl 作為初始化參數,以準備播放 "hit-sometingShort.mp3"
let trashCanPlayerItem = AVPlayerItem(url: trashCanUrl)
// 中間省略

// 在 if else 那一段每個選項內設置(垃圾桶的話就將 sandBagPlayerItem 改成 trashCanPlayerItem)
player.replaceCurrentItem(with: sandBagPlayerItem) // 替換目前音效為沙袋音效
player.play() // 使用 AVPlayer 的 play 方法播放聲音

第三階段成果:

App 啟動畫面

因為在找圖片跟音效的時候,找到了鈴和鈴聲,不用可惜。所以如果能放在App 的啟動畫面就物盡其用了(?)

在 LaunchScreen 裡設計畫面。

完成了!!!

Debug

其實在最後錄影的時候,還發現了一個 bug。因為第二步驟的時候,在 punchCountButton 裡添加了點擊 30 次以後換拳擊手圖片的判斷式。但忘了在 segmentedControl 裡,每更換一次頁籤就會重新呼叫一次拳擊手圖片。會導致,如果女拳擊手已經超過 30 下變成角落休息的女拳擊手,但切換到垃圾桶,再切換回沙袋,女拳擊手又站起來了!但同時她的出拳次數還停在 30 幾。回去 segmentedControl 加入條件式即解決了這問題。

謝謝收看!

Code:

import UIKit
// 添加音樂需要先導入負責影音的 AVFoundation
import AVFoundation


class ViewController: UIViewController {
// 增加 AVAudioPlayer 的 instance 負責播放音樂
var player = AVPlayer()
// punchThingSegmentedControl 是上方的沙袋or垃圾桶選項
@IBOutlet weak var punchThingSegmentedControl: UISegmentedControl!
// 出拳次數下方的 Label,負責顯示統計點擊的數字
@IBOutlet weak var punchCountLabel: UILabel!
// +1 圖片 Image View
@IBOutlet weak var plusOneImageView: UIImageView!
// 中央放沙袋與垃圾桶的 Image View
@IBOutlet weak var sandBagImageView: UIImageView!
// 右上角放女拳擊手跟男拳擊手的 Image View
@IBOutlet weak var boxerImageView: UIImageView!

// 預設 segmentedPage 在第一個頁籤,因為第一個的 index 是 0,所以 = 0 。
var segmentedPageIndex = 0
// 因為兩個物品的點擊數量要分開儲存,所以各設定一個變數。
var sandBagPunchCount = 0 // 沙袋點擊數
var trashCanPunchCount = 0 // 垃圾桶點擊數
func changeBoxerImage(){
if boxerImageView.image == UIImage(named: "sports_boxing_woman"){
boxerImageView.image = UIImage(named: "sports_boxing_corner_woman")
} else{
boxerImageView.image = UIImage(named: "sports_boxing_corner_man")
}
}

//這裡是 View Controller 一開始必執行的地方,因為剛進入頁面的時候,還沒點擊 segmentedControl,所以沙袋頁沒辦法運作,點了也不會有反應。一定要看到沙袋的 Image View 與沙袋點擊數才會運作。
override func viewDidLoad() {
super.viewDidLoad()

// 顯示沙袋圖
sandBagImageView.image = UIImage(named: "boxing_sandbag")
// +1 圖片先隱藏著
plusOneImageView.isHidden = true
// 取得沙袋音效檔案的 url,forResource 傳入檔案名稱,withExtension 傳入附檔名
let boxingBellUrl = Bundle.main.url(forResource: "boxing-bell", withExtension: "mp3")!
// 創建 AVPlayerItem,使用 sandBagUrl 作為初始化參數,以準備播放 "punchShort.mp3"
let boxingBellPlayerItem = AVPlayerItem(url: boxingBellUrl)
player.replaceCurrentItem(with: boxingBellPlayerItem)
player.play()

}

// 在 segmentedControl 點選沙袋或垃圾桶的函數
@IBAction func segmentedControl(_ sender: UISegmentedControl) {
// selectedSegmentIndex 讀取我們點擊了 segmentedControl 中的第幾個頁籤
segmentedPageIndex = punchThingSegmentedControl.selectedSegmentIndex
//如果讀取第一個頁籤 沙袋,並且沙袋已被點擊 30 次
if segmentedPageIndex == 0 && sandBagPunchCount >= 30{
// 呼叫角落休息的女拳擊手
boxerImageView.image = UIImage(named: "sports_boxing_corner_woman")
// 正中央擺沙袋圖
sandBagImageView.image = UIImage(named: "boxing_sandbag")
// 出拳次數下方的數字使用沙袋被點擊的記數
punchCountLabel.text = "\(sandBagPunchCount)"
} //如果讀取第一個頁籤是沙袋
else if segmentedPageIndex == 0 {
// 呼叫女拳擊手圖片
boxerImageView.image = UIImage(named: "sports_boxing_woman")
// 正中央擺沙袋圖
sandBagImageView.image = UIImage(named: "boxing_sandbag")
// 出拳次數下方的數字使用沙袋被點擊的記數
punchCountLabel.text = "\(sandBagPunchCount)"
// 如果讀取第二個頁籤是垃圾桶,並且垃圾桶已被點擊 30 次
}else if segmentedPageIndex == 1 && trashCanPunchCount >= 30{
boxerImageView.image = UIImage(named: "sports_boxing_corner_man")
// 正中央擺垃圾桶圖
sandBagImageView.image = UIImage(named: "gomi_poribaketsu_close")
// 出拳次數下方的數字使用沙袋被點擊的記數
punchCountLabel.text = "\(trashCanPunchCount)"
} else { //第四選項 如果讀取第二個頁籤是垃圾桶
// 呼叫男拳擊手圖片
boxerImageView.image = UIImage(named: "sports_boxing_man")
// 正中央擺垃圾桶圖
sandBagImageView.image = UIImage(named: "gomi_poribaketsu_close")
// 出拳次數下方的數字使用沙袋被點擊的記數
punchCountLabel.text = "\(trashCanPunchCount)"
}

}

// 正中央隱形的 Button,被點擊後的動作
@IBAction func punchCountButton(_ sender: Any) {
// 取得沙袋音效檔案的 url,forResource 傳入檔案名稱,withExtension 傳入附檔名
let sandBagUrl = Bundle.main.url(forResource: "punchShort", withExtension: "mp3")!
// 創建 AVPlayerItem,使用 sandBagUrl 作為初始化參數,以準備播放 "punchShort.mp3"
let sandBagPlayerItem = AVPlayerItem(url: sandBagUrl)
// 取得垃圾桶音效檔案的 url
let trashCanUrl = Bundle.main.url(forResource: "hit-sometingShort", withExtension: "mp3")!
// 創建 AVPlayerItem,使用 trashCanUrl 作為初始化參數,以準備播放 "hit-sometingShort.mp3"
let trashCanPlayerItem = AVPlayerItem(url: trashCanUrl)
// 顯示 +1 圖片
plusOneImageView.isHidden = false
// 創建 UIViewPropertyAnimator
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .linear) {
// 在這裡設定 plusOneImageView 向上移動 5 點
self.plusOneImageView.frame.origin.y -= 5
}

// 動畫完成後的處理(這裡隱藏 plusOneImageView 並移回原始位置)
animator.addCompletion { (position) in
if position == .end {
self.plusOneImageView.isHidden = true
self.plusOneImageView.frame.origin.y += 5
}
}
// 開始動畫
animator.startAnimation()

// 如果被點擊的當下是沙袋圖,並且沙袋已被點擊 30 次
if sandBagImageView.image == UIImage(named: "boxing_sandbag") && sandBagPunchCount >= 30{
// 則沙袋被點擊數量 + 1
sandBagPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(sandBagPunchCount)"
// 出現角落休息的女拳擊手
boxerImageView.image = UIImage(named: "sports_boxing_corner_woman")
player.replaceCurrentItem(with: sandBagPlayerItem)
player.play()
}
// 如果被點擊的當下是沙袋圖,
else if sandBagImageView.image == UIImage(named: "boxing_sandbag"){
// 則沙袋被點擊數量 + 1
sandBagPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(sandBagPunchCount)"
player.replaceCurrentItem(with: sandBagPlayerItem)
player.play()
// 如果被點擊的當下是垃圾桶圖,並且垃圾桶已被點擊 30 次
}else if sandBagImageView.image == UIImage(named: "gomi_poribaketsu_close") && trashCanPunchCount >= 30{
// 則垃圾桶被點擊數量 + 1
trashCanPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(trashCanPunchCount)"
// 出現角落休息的男拳擊手
boxerImageView.image = UIImage(named: "sports_boxing_corner_man")
player.replaceCurrentItem(with: trashCanPlayerItem)
player.play()
// 如果被點擊的當下是垃圾桶圖,
}else if sandBagImageView.image == UIImage(named: "gomi_poribaketsu_close"){
// 則垃圾桶被點擊數量 + 1
trashCanPunchCount += 1
// +1 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(trashCanPunchCount)"
player.replaceCurrentItem(with: trashCanPlayerItem)
player.play()
}
}
// 點擊重新來過 Button 的動作
@IBAction func playAgainButton(_ sender: Any) {
// 如果被點擊的當下是沙袋圖,
if sandBagImageView.image == UIImage(named: "boxing_sandbag"){
// 則沙袋被點擊數量歸 0
sandBagPunchCount = 0
// 歸 0 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(sandBagPunchCount)"
boxerImageView.image = UIImage(named: "sports_boxing_woman")
// 如果被點擊的當下是垃圾桶圖,
}else if sandBagImageView.image == UIImage(named: "gomi_poribaketsu_close"){
// 則垃圾桶被點擊數量歸 0
trashCanPunchCount = 0
// 歸 0 後的數字更新到顯示數量的 punchCountLabel 中
punchCountLabel.text = "\(trashCanPunchCount)"
boxerImageView.image = UIImage(named: "sports_boxing_man")
}
}

}

GitHub:

Reference:

--

--