swiftPractice[22]_拍手遊戲 APP

練習:UITapGestureRecognizer, Timer, AVPlayer, UIViewPropertyAnimator, UIAlertController

Tania
彼得潘的 Swift iOS / Flutter App 開發教室
30 min readJan 15, 2024

--

又是一個越做越貪心,想要更多功能,結果走火入魔做不完的小遊戲😂由於技術跟不上野心,這個是經過無數撞牆後收斂的版本,也是這個作業過後,我決定還是先紮實的把基本功練好再來滿足我的貪心~趕快來進入內文,看看 30 秒內我可以拍幾下吧!!

遊戲畫面Demo

APP 功能:

● 點選畫面人物拍手可讓數字+1
● 在畫面上加入特別的按鈕,讓拍手數量有特別的變化(-10)
● 點選餅乾或按鈕時有音效
● 點選餅乾或按鈕時有動畫效果,並且出現數字變化的文字
● 倒數計時
● 計算點按速率
● 遊戲結束提醒

拆解!

<拆解目錄>
1.畫面規劃、拉 IBOutlet & IBAction
2.設定初始畫面
3.IBAction 功能一:startGame(_ sender: UIButton)
• 倒數計時 Timer
• 遊戲結束提醒 func endGameAlert()
• 點按速率計算 func speedUpdate()

4.IBAction 功能二:tap(_ sender: UITapGestureRecognizer)
• 拍手音效
• 拍手Icon動畫
• 扣分陷阱按鈕動畫 func trap()

5.IBAction 功能三:minus(_ sender: UIButton)
• 扣分音效
• 扣分Label動畫

6.IBAction 功能四:playAgain(_ sender: UIButton)

1. 畫面規劃、拉 IBOutlet & IBAction

@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var timeLabel: UILabel!
@IBOutlet weak var countLabel: UILabel!
@IBOutlet weak var speedLabel: UILabel!
@IBOutlet weak var minusButton: UIButton!
@IBOutlet weak var startButton: UIButton!
@IBOutlet weak var minusLabel: UILabel!

2. 設定初始畫面

    override func viewDidLoad() {
super.viewDidLoad()

//設定遊戲時間
secondRemain = 30
timeLabel.text = "\(secondRemain)"
//設定點按次數計算
count = 0
countLabel.text = "0"
//設定速度Label
speedLabel.text = "0.0"
//設計扣分按鈕
minusButton.frame = CGRect(x: 150, y: 300, width: 100, height: 100)
minusButton.titleLabel!.text = "-10"
minusButton.layer.cornerRadius = 50
minusButton.clipsToBounds = true
minusButton.alpha = 0.5

//開始遊戲前限制畫面點按
imageView.isUserInteractionEnabled = false
//扣分按鈕隱藏
minusButton.isHidden = true
//讓開始按鈕可以使用
startButton.isEnabled = true
//扣分標籤隱藏
minusLabel.isHidden = true
}

3. IBAction 功能一:startGame(_ sender: UIButton)

遊戲開始按鈕:按下後計時開始、原本按鈕會消失、可以點擊畫面拍手、扣分陷阱按鈕出現

<功能一完整程式>

//遊戲開始鍵
@IBAction func startGame(_ sender: UIButton) {
//開始後圖片可以點
imageView.isUserInteractionEnabled = true
//按下開始後讓開始鍵不能按(他會消失)
startButton.isEnabled = false
//扣分按鈕出現
minusButton.isHidden = false

//計時功能
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [self] Timer in
//計時未結束前
if secondRemain >= 1 {
secondRemain -= 1
timeLabel.text = "\(secondRemain)"
speedUpdate()
//計時結束
}else{
timer?.invalidate()
//圖片不能繼續點
imageView.isUserInteractionEnabled = false
endGameAlert()
}
})
}

• 倒數計時 Timer

先在 Controller 最前面宣告 Timer

var timer:Timer?

設定觸發間隔時間為1秒、重複觸發

剩餘時間 > 0 之前秒數遞減 1 (倒數計時)、且每秒更新玩家點按速率

計時結束,用 .invalidate( ) 停止計時器、限制畫面點按、彈出遊戲結束提醒

 
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [self] Timer in
//計時未結束前
if secondRemain >= 1 {
secondRemain -= 1
timeLabel.text = "\(secondRemain)"
speedUpdate()
//計時結束
}else{
timer?.invalidate()
//圖片不能繼續點
imageView.isUserInteractionEnabled = false
endGameAlert()
}
})

• 遊戲結束提醒 func endGameAlert( )

設定 UIAlertController 標題內後,使用.addTextField 從程式加入 TextField,並在 closure 裡用.placeholder 設定使用者輸入前顯示的文字。

設定 UIAlertAction 文字為 save,這邊我本來想要讓使用者輸入名字後儲存分數資料到下一頁,並在下一頁呈現排名表格,但是沒有很順利,所以先設定按鍵回到初始畫面可以重新開始遊戲。(排名未來再好好研究完成)

✽ 設定完後記得將 Action 加入 AlertController 並且要 present 做好的 Controller 才會出現在畫面上。

    //遊戲結束通知功能
func endGameAlert(){
let controller = UIAlertController(title: "Time's Up!", message: "Your score is \(count).", preferredStyle: .alert)

//編輯alert裡的textfield,in前面的名稱設定後才能做裡面的調整
controller.addTextField{ textField in
textField.placeholder = "Enter your name"
}

let saveAction = UIAlertAction(title: "save", style: .default) { [self] _ in
viewDidLoad()
}
controller.addAction(saveAction)
present(controller, animated: true)
}

• 點按速率計算 func speedUpdate( )

這是在測試的時候因為很好奇自己到底可以按多快,因而產生的功能!

點按總數/使用的時間 得到速率,在使用 「 %.1f 」 將數字取到小數點後一位

    //計算點按速度功能
func speedUpdate(){
//計算經過時間
let secondPassed = 30 - secondRemain
//速度=點按總數/經過時間
speed = Float(count) / Float( secondPassed)
//設定速度label,到小數點後一位
speedLabel.text = String(format: "%.1f", speed)
}

4. IBAction 功能二:tap(_ sender: UITapGestureRecognizer)

整個遊戲的重點就是 “tap”,這邊我把 UITapGestureRecognizer 加在李奧納多的圖片上來偵測使用者點按手勢,再透過音效、Icon 動畫和陷阱增加玩家體驗。

<功能二完整程式>

//點按區塊(李奧納多)
@IBAction func tap(_ sender: UITapGestureRecognizer) {
//計算點按次數/
count += 1
countLabel.text = "\(count)"

//設置拍手音效
let clapUrl = Bundle.main.url(forResource: "clapSound", withExtension: "mp3")
let clapPlayerItem = AVPlayerItem(url: clapUrl!)
player.replaceCurrentItem(with: clapPlayerItem)
player.play()

//設置拍手emoji動畫
//儲存使用者點按的位置
let tapLocation = sender.location(in: view)

let clapIcon = UIImageView(image: UIImage(named: "hands"))
let iconSize = CGSize(width: 70, height: 70)
//設定icon位置為點按位置
clapIcon.frame = CGRect(origin: tapLocation, size: iconSize)
view.addSubview(clapIcon)

UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1, delay: 0) {
clapIcon.frame.origin.y -= 100
} completion: { _ in
clapIcon.removeFromSuperview()
}
trap()

}

• 拍手音效

加入播放音效需要的函示庫

import AVFoundation

在 Controller 最前面宣告 AVPlayer ( )

var player = AVPlayer()

Bundle.main.url 類似於主要資料夾裡的位址,forResource:輸入檔名,withExtension: 輸入副檔名。再使用 AVPlayerItem 傳入音效 url 生成要播放的音效。

不管原本 player 裡播放的音效是什麼,使用.replaceCurrentItem(with: clapPlayerItem) 傳入我們的音效取代,再呼叫 .play( )播放。

        //設置拍手音效
let clapUrl = Bundle.main.url(forResource: "clapSound", withExtension: "mp3")
let clapPlayerItem = AVPlayerItem(url: clapUrl!)
player.replaceCurrentItem(with: clapPlayerItem)
player.play()

✽音效檔要放在跟 Asset 同等地位的地方,不是 Asset 裡面,不然會沒辦法使用 Bundle.main.url 的路徑讀取

• 拍手Icon動畫

拍手動畫Demo

這是跟 chatGPT 討論得出的作法,前輩們的作法是讓隱藏的畫面顯示出來並加上位移動畫,但我想要像對直播主按愛心那樣,可以看出數量跟速度感的動畫,而且 Icon 最好可以從點按的位置出現!

為了讓 Icon 可以點到哪就從哪冒出來,需要先用 sender.location(in: view) 讀出使用者在畫面上點按的位置存入常數 tapLocation。

我事先把拍手 emoji 截成圖片並去背,(我覺得這樣會比從程式生成字串 emoji 再轉成圖片方便)設定好圖片細節後,將位置設為剛剛讀取好的 tapLocation,記得加到畫面上。

使用 UIViewPropertyAnimator 裡的 .runningPropertyAnimator 功能,動畫時間設定 1 秒,之後 「動畫 animations」 跟 「完成動畫 completion」 分別是兩個closure,動畫設定 Icon會往上 100,結束之後使用 .removeFromSuperview( )把它移除,不然畫面上會越來越多手。🤣

        //設置拍手emoji動畫
//儲存使用者點按的位置
let tapLocation = sender.location(in: view)

let clapIcon = UIImageView(image: UIImage(named: "hands"))
let iconSize = CGSize(width: 70, height: 70)
//設定icon位置為點按位置
clapIcon.frame = CGRect(origin: tapLocation, size: iconSize)
view.addSubview(clapIcon)
//withDuration 是動畫時間
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1, delay: 0) {
clapIcon.frame.origin.y -= 100
} completion: { _ in
clapIcon.removeFromSuperview()
}

✽登登登結果可愛又滿意!補充:舊版的動畫使用 UIView.animate 一樣可以達成,但是新的 UIViewPropertyAnimator 有更多細節、變化可以調整!

• 扣分陷阱按鈕動畫 func trap( )

這就是原本野心很大結果做不出來折衷妥協的部分!可以看到我土法煉鋼用力的使用 UIViewPropertyAnimator 來移動我的扣分按鈕。

我將這個功能放在點按功能裡,搭配點按記數,來決定扣分按鈕移動的時間點,隨著分數越高動畫速度會加快。

▵▵但是按鈕在移動時,點按功能沒有辦法正常運作,要到就定位後才能使用,這是目前最大的bug,解決方案尋找中…

    //扣分按鈕動畫
func trap(){
if count == 10{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 2, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 150, y: 400)
}
}
if count == 30{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 2, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 250, y: 400)
}
}
if count == 50{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 3, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 150, y: 500)
}
}
if count == 70{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 2, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 50, y: 450)
}
}
if count == 100{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 150, y: 400)
}
}
if count == 120{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 80, y: 480)
}
}
if count == 140{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 150, y: 480)
}
}
if count == 150{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 40, y: 440)
}
}
if count == 160{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.3, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 120, y: 380)
}
}
if count == 170{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.3, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 120, y: 320)
}
}
if count == 175{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.2, delay: 0) { [self] in

minusButton.frame.origin = CGPoint(x: 120, y: 500)
}
}
}

🖤 順便分享一下胎死腹中的作法,原本是利用 UIBezierPath 畫出鬼一樣的路徑,再把按鈕中心設定為起始點,但是同上面問題,整個移動過程中按鈕都無法點按扣分,只剩嚇唬玩家的功能,於是改成現在的版本。

失敗的原始路徑Demo

5.IBAction 功能三:minus(_ sender: UIButton)

扣分功能:玩家按到扣分按鈕時 count -10 、且出現 -10 數字動畫

<功能三完整程式>

//扣分功能
@IBAction func minus(_ sender: UIButton) {
count -= 10
countLabel.text = "\(count)"
//設置扣分音效
let url = Bundle.main.url(forResource: "minusSound", withExtension: "mp3")
let playerItem = AVPlayerItem(url: url!)
player.replaceCurrentItem(with: playerItem)
player.play()
//設置扣分數字動畫
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0) { [self] in
minusLabel.isHidden = false
minusLabel.frame.origin.x += 20
} completion: { [self] _ in
minusLabel.isHidden = true
minusLabel.frame.origin.x -= 20
}

}

• 扣分音效

方法同前面拍手音效

//設置扣分音效
let url = Bundle.main.url(forResource: "minusSound", withExtension: "mp3")
let playerItem = AVPlayerItem(url: url!)
player.replaceCurrentItem(with: playerItem)
player.play()

• 扣分 Label 動畫

先在 Storyboard 做一個內容為 「-10」 的 Label 並在 ViewDidLoad( ) 裡隱藏,等玩家按到扣分按鈕時再顯示。

動畫設定向右位移 20 ,有別於前面拍手Icon位置設定在點按位置,這邊設定的是一個定點,所以結束後要把他移回來,如果只把它隱藏,下一次出發動畫時,就會在每次停下來的點往右跑,一直到離開你的視線。

//設置扣分數字動畫
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0) { [self] in
minusLabel.isHidden = false
minusLabel.frame.origin.x += 20
} completion: { [self] _ in
minusLabel.isHidden = true
minusLabel.frame.origin.x -= 20
}
扣分音效、動畫Demo

6.IBAction 功能四:playAgain(_ sender: UIButton)

計時器不會自己停止,記得使用.invalidate( ) 終止它

使用 viewDidLoad( ) 初始所有數值

    //重玩
@IBAction func playAgain(_ sender: UIButton) {
//停止計時器
timer?.invalidate()
viewDidLoad()
}

APP Demo

完整程式碼

import UIKit
import AVFoundation

class GameViewController: UIViewController {

@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var timeLabel: UILabel!
@IBOutlet weak var countLabel: UILabel!
@IBOutlet weak var speedLabel: UILabel!
@IBOutlet weak var minusButton: UIButton!
@IBOutlet weak var startButton: UIButton!
@IBOutlet weak var minusLabel: UILabel!

var secondRemain = 30
var count:Int = 0
var speed:Float = 0
var timer:Timer?
var player = AVPlayer()

override func viewDidLoad() {
super.viewDidLoad()
//設定遊戲時間
secondRemain = 30
timeLabel.text = "\(secondRemain)"
//設定點按次數計算
count = 0
countLabel.text = "0"
//設定速度Label
speedLabel.text = "0.0"
//設計扣分按鈕
minusButton.frame = CGRect(x: 150, y: 300, width: 100, height: 100)
minusButton.titleLabel!.text = "-10"
minusButton.layer.cornerRadius = 50
minusButton.clipsToBounds = true
minusButton.alpha = 0.5

//開始遊戲前限制畫面點按
imageView.isUserInteractionEnabled = false
//扣分按鈕隱藏
minusButton.isHidden = true
//讓開始按鈕可以使用
startButton.isEnabled = true
//扣分標籤隱藏
minusLabel.isHidden = true
}

//計算點按速度功能
func speedUpdate(){
//計算經過時間
let secondPassed = 30 - secondRemain
//速度=點按總數/經過時間
speed = Float(count) / Float( secondPassed)
//設定速度label,到小數點後一位
speedLabel.text = String(format: "%.1f", speed)
}

//遊戲結束通知功能
func endGameAlert(){
let controller = UIAlertController(title: "Time's Up!", message: "Your score is \(count).", preferredStyle: .alert)

//編輯alert裡的textfield,in前面的名稱設定後才能做裡面的調整
controller.addTextField{ textField in
textField.placeholder = "Enter your name"
}

let saveAction = UIAlertAction(title: "save", style: .default) { [self] _ in
viewDidLoad()
}
controller.addAction(saveAction)
present(controller, animated: true)
}
//扣分按鈕動畫
func trap(){
if count == 10{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 2, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 150, y: 400)
}
}
if count == 30{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 2, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 250, y: 400)
}
}
if count == 50{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 3, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 150, y: 500)
}
}
if count == 70{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 2, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 50, y: 450)
}
}
if count == 100{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 150, y: 400)
}
}
if count == 120{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 80, y: 480)
}
}
if count == 140{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 150, y: 480)
}
}
if count == 150{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 40, y: 440)
}
}
if count == 160{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.3, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 120, y: 380)
}
}
if count == 170{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.3, delay: 0) { [self] in
minusButton.frame.origin = CGPoint(x: 120, y: 320)
}
}
if count == 175{
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.2, delay: 0) { [self] in

minusButton.frame.origin = CGPoint(x: 120, y: 500)
}
}
}
//遊戲開始鍵
@IBAction func startGame(_ sender: UIButton) {
//開始後圖片可以點
imageView.isUserInteractionEnabled = true
//按下開始後讓開始鍵不能按(他會消失)
startButton.isEnabled = false
//扣分按鈕出現
minusButton.isHidden = false
//計時功能
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [self] Timer in
//計時未結束前
if secondRemain >= 1 {
secondRemain -= 1
timeLabel.text = "\(secondRemain)"
speedUpdate()
//計時結束
}else{
timer?.invalidate()
//圖片不能繼續點
imageView.isUserInteractionEnabled = false
endGameAlert()
}
})
}

//點按區塊(李奧納多)
@IBAction func tap(_ sender: UITapGestureRecognizer) {
//計算點按次數/
count += 1
countLabel.text = "\(count)"

//設置拍手音效
let clapUrl = Bundle.main.url(forResource: "clapSound", withExtension: "mp3")
let clapPlayerItem = AVPlayerItem(url: clapUrl!)
player.replaceCurrentItem(with: clapPlayerItem)
player.play()

//設置拍手emoji動畫
//儲存使用者點按的位置
let tapLocation = sender.location(in: view)

let clapIcon = UIImageView(image: UIImage(named: "hands"))
let iconSize = CGSize(width: 70, height: 70)
//設定icon位置為點按位置
clapIcon.frame = CGRect(origin: tapLocation, size: iconSize)
view.addSubview(clapIcon)

UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1, delay: 0) {
clapIcon.frame.origin.y -= 100
} completion: { _ in
clapIcon.removeFromSuperview()
}
trap()
}

//扣分功能
@IBAction func minus(_ sender: UIButton) {
count -= 10
countLabel.text = "\(count)"
//設置扣分音效
let url = Bundle.main.url(forResource: "minusSound", withExtension: "mp3")
let playerItem = AVPlayerItem(url: url!)
player.replaceCurrentItem(with: playerItem)
player.play()
//設置扣分數字動畫
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0) { [self] in
minusLabel.isHidden = false
minusLabel.frame.origin.x += 20
} completion: { [self] _ in
minusLabel.isHidden = true
minusLabel.frame.origin.x -= 20
}
}

//重玩
@IBAction func playAgain(_ sender: UIButton) {
//停止計時器/
timer?.invalidate()
viewDidLoad()
}
}

後記

呼!這個作業太好玩了,但是先玩到這,還是要多唸點書、一步一步練習才行,不然每個作業我都要卡一個禮拜繞一大圈,最終還是沒有做出原本計畫的樣子😅😅

▲▲ 尚未完成的功能:移動且可以正常運作的扣分按鈕跟分數排行榜

作業出處:

GitHub

--

--

Tania
彼得潘的 Swift iOS / Flutter App 開發教室

A barista's journey transitioning into iOS development, documenting projects and learning experiences in a dedicated blog. 朝{ 咖啡師+工程師 } 努力中