swiftPractice[25]_喝酒骰子遊戲_協作

UI設計:Wendy, 程式設計:Tania

Tania
彼得潘的 Swift iOS / Flutter App 開發教室
19 min readMar 9, 2024

--

如題,這是第一個協作作業,app 介面部分由學習中的 UI 設計師朋友 Wendy 負責,我負責用程式實現功能!朋友嗜酒如命(?依照她的需求設計了這個簡單實用的骰子工具,一個按鍵、五個骰子、順子重骰、喝一杯吧~one shot!

APP 功能

1.按下按鈕,亂數決定五顆骰子點數
2.決定遇到順子是否要重骰的開關
3.順子提醒 Alert

拆解!

拆解目錄
1.研究設計圖
2.建立 IBOutlet & IBAction
3.骰子部分
3-1.骰子分佈位置組合&動畫
3-2.亂數決定骰子點數
3-3.音效
4.start按鈕功能部分
4-1.特殊字體設置
4-2.安排骰子位置出現順序
4-3.判斷順子
4-4.出現順子 Alert

1. 研究設計圖

由於我們沒有使用付費功能,沒辦法直接得到程式碼。在不算熟悉這個工具的狀況下,我先暴力的就我現有的資訊去推算需要的資訊,例如座標。

說到座標我遇到的問題是,Figma檔案中的物件座標是左上角xy座標,旋轉會跟著改變,所以我得到的會是已旋轉圖片的左上角座標。

以這個骰子app的實例來說,為了讓畫面稍微符合現實,我們準備了五種不同的骰子分佈樣貌,每按一次按鈕,骰子會搭配動畫移動到定點,並且擁有不同旋轉角度,代表我不能使用已旋轉好的骰子圖檔了事,因為換到下一個位置時,反而要換算新的旋轉角度。

於是我使用的當下想到的傻方法,把所有Figma檔案裡的骰子轉正,得知原始位置後,再用程式賦予各自的角度,回想起來就覺得很累(笑。

執行之後另一問題出現,如果定位後旋轉的 ImageView 在下一次移動並旋轉的動畫中,會因為內部矩陣運算的緣故變小,甚至消失不見。關於運算跟作用原理我還沒研究完畢,但我先找了替代方案,也就是如果使用中心作為定位點就不受影響。

於是,我又暴力換算了每顆骰子的中心座標(這就是所有座標都有”+65”的由來)。

問題還沒結束,程式跑起來,骰子會動能轉,但是位置竟然跟設計圖不一樣!因為我忽略了設計師有設一個等距的 padding,也就是圖片邊界跟邊框有個一個距離!我在算座標的時候沒有算到,於是每個xy座標都少了16。

以上是第一次協作的心路歷程,如果可以先花時間摸熟 Figma,可能會好一點。

2. 建立 IBOutlet & IBAction

3. 骰子部分

3-1. 骰子分佈位置組合&動畫

這部分因為內容太多,我把它移進 Extension,並且另外開了一個資料夾。

骰子的部分我全部拉成一個 outlet collection,分別指定中心座標後,在指定旋轉角度,並使用 UIViewPropertyAnimator.runningPropertyAnimator 加入長度 0.6 秒的動畫。每一個位置都是一個 Function,方便後面安排使用的時機。

extension ViewController{
func positionA() {
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.6, delay: 0) { [self] in
diceCollection[0].center = CGPoint(x:240.33+65, y: 602.33+65)
diceCollection[1].center = CGPoint(x: 18.67+65, y: 666.33+65)

diceCollection[2].center = CGPoint(x: 38.67+65, y: 410.67+65)
diceCollection[3].center = CGPoint(x: 204+65, y: 472+65)
diceCollection[4].center =
CGPoint(x: 120.67+65, y: 568.67+65)

diceCollection[0].transform = CGAffineTransform(rotationAngle: oneDegree * 1.28)
diceCollection[1].transform = CGAffineTransform(rotationAngle: oneDegree * 36)
diceCollection[2].transform = CGAffineTransform.identity.rotated(by: oneDegree*24)
//figma角度正數從360扣
diceCollection[3].transform = CGAffineTransform(rotationAngle: oneDegree * 306.94)
diceCollection[4].transform = CGAffineTransform(rotationAngle: oneDegree * 341)
}

}


func positionB(){
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.6, delay: 0) { [self] in
diceCollection[0].center = CGPoint(x: 248+65, y: 266+65)
diceCollection[1].center = CGPoint(x: 173.34+65, y: 405.03+65)

diceCollection[2].center = CGPoint(x: 181.72+65, y: 614.72+65)
diceCollection[3].center = CGPoint(x: 51.94+65, y: 499.94+65)
diceCollection[4].center =
CGPoint(x: 52.9+65, y: 679.9+65)

diceCollection[0].transform = CGAffineTransform(rotationAngle: oneDegree * 1.28)
diceCollection[1].transform = CGAffineTransform(rotationAngle: oneDegree * 36)
diceCollection[2].transform = CGAffineTransform.identity.rotated(by: oneDegree*24)
//figma角度正數從360扣
diceCollection[3].transform = CGAffineTransform(rotationAngle: oneDegree * 306.94)
diceCollection[4].transform = CGAffineTransform(rotationAngle: oneDegree * 341)
}

}

func positionC(){
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.6, delay: 0) { [self] in
diceCollection[0].center = CGPoint(x: 66.31+65, y: 326.16+65)
diceCollection[1].center = CGPoint(x: 203.4+65, y: 693.5+65)

diceCollection[2].center = CGPoint(x: 189.72+65, y: 367.72+65)
diceCollection[3].center = CGPoint(x: 103.94+65, y: 456.94+65)
diceCollection[4].center =
CGPoint(x: 247.13+65, y: 559.13+65)

diceCollection[0].transform = CGAffineTransform(rotationAngle: oneDegree * 330.82)
diceCollection[1].transform = CGAffineTransform(rotationAngle: oneDegree * 356.9)
diceCollection[2].transform = CGAffineTransform.identity.rotated(by: oneDegree*24)
diceCollection[3].transform = CGAffineTransform(rotationAngle: oneDegree * 10.68)
diceCollection[4].transform = CGAffineTransform(rotationAngle: oneDegree * 65.52)
}
}
func positionD(){
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.6, delay: 0) { [self] in
diceCollection[0].center = CGPoint(x: 256.44+65, y: 618.47+65)
diceCollection[1].center = CGPoint(x: 103.35+65, y: 672.71+65)

diceCollection[2].center = CGPoint(x: 81.38+65, y: 454.84+65)
diceCollection[3].center = CGPoint(x: 220.12+65, y: 488.12+65)
diceCollection[4].center =
CGPoint(x: 216.5+65, y: 272.5+65)

diceCollection[0].transform = CGAffineTransform(rotationAngle: oneDegree * 1.28)
diceCollection[1].transform = CGAffineTransform(rotationAngle: oneDegree * 35.96)
diceCollection[2].transform = CGAffineTransform.identity.rotated(by: oneDegree*2.98)
//figma角度正數從360扣
diceCollection[3].transform = CGAffineTransform(rotationAngle: oneDegree * 343)
diceCollection[4].transform = CGAffineTransform(rotationAngle: oneDegree * 330.8)
}
}
func positioE(){
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.6, delay: 0) { [self] in
diceCollection[0].center = CGPoint(x: 52.4+65, y: 351.5+65)
diceCollection[1].center = CGPoint(x: 114.34+65, y: 484.03+65)

diceCollection[2].center = CGPoint(x: 257.79+65, y: 686.79+65)
diceCollection[3].center = CGPoint(x: 94.01+65, y: 652.01+65)
diceCollection[4].center =
CGPoint(x: 218.89+65, y: 545.89+65)

diceCollection[0].transform = CGAffineTransform(rotationAngle: oneDegree * 356.9)
diceCollection[1].transform = CGAffineTransform(rotationAngle: oneDegree * 34.7)
diceCollection[2].transform = CGAffineTransform.identity.rotated(by: oneDegree*11.37)
diceCollection[3].transform = CGAffineTransform(rotationAngle: oneDegree * 359.95)
diceCollection[4].transform = CGAffineTransform(rotationAngle: oneDegree * 342.07)
}
}

3-2. 亂數決定骰子點數

利用 for in loop + random(in:) 亂數決定骰子點數、設定每個骰子 ImageView,然後將點數塞進一個 Array 裡後面會用到。

//亂數生成骰子點數並作成array
for dice in diceCollection{
let randomDice = Int.random(in: 1...6)
dice.image = UIImage(named: "dice\(randomDice)")
diceArray.append(randomDice)
}

3-3.音效

音效檔來自 https://pixabay.com/。音檔要放在跟 Asset資料夾同等位置的地方。

先生成 url,輸入檔名跟副檔名,再傳入AVPlayerItem 做準備,使用.replaceCurrentItem(with: ) 將 PlayerItem 放在即將播放的位置,最後記得 player.play() 才會播放。

//骰子音效
let diceSoundUrl = Bundle.main.url(forResource: "diceSound", withExtension: "m4a")!
let playerItem = AVPlayerItem(url: diceSoundUrl)
player.replaceCurrentItem(with: playerItem)
player.play()

4. start按鈕功能部分

4-1.特殊字體設置

特殊字體的部分是 Button 上的字體,網路下載字體檔後同樣加入 Project navigator 即可,檔案格式可以是 ttf ,otf 或 ttc 三種。

4-2.安排骰子位置出現順序

先在最前面宣告點按次數,每當使用者按下按鍵 click 會 +1 。由於預先設計好的位置有五種,我用 click % 5 的餘數判斷順序,搭配 switch case 讓位置依序出現。

  var click = 0
  //決定骰子位置樣式
switch click % 5{
case 1:
positionA()
case 2:
positionB()
case 3:
positionC()
case 4:
positionD()
default:
positioE()
}

4-3.判斷順子

設計一個 function 參數是 array,回傳 Bool。這裡的 array 就是前面生成亂數骰子時利用 append( ) 生成的。

.sorted( ) 可以讓 array 裡的數字由小而大排列。再使用迴圈逐項檢查相鄰數字間隔不為 1 時回傳 false,檢查不出 false 時回傳 true 代表是順子。

//判斷順子function
func isStraight(dice:[Int])->Bool{
let sortedDiceArray = diceArray.sorted()
print(sortedDiceArray)

for i in 0..<sortedDiceArray.count - 1{
if sortedDiceArray[i] + 1 != sortedDiceArray[i + 1]{
return false
}
}
return true
}

4-4.出現順子 Alert

呼叫 Alert 前要先判斷順子開關的狀態,如果是開啟的,才需要判斷順子並做出提醒。

If else 的條件句就是判斷為 true 的時候,可以執行大括弧的程式,所以這裡我們可以同時判斷 straightSwitch.isOn 和 isStraight(dice: diceArray)。

使用 UIAlertController(title: , message: , preferredStyle: ) 生成提醒視窗,輸入標題、內容跟樣式,.addAction 則可以編輯視窗下的按鈕。

其中 handler 後面的 closure 可以決定按下按鈕後要做什麼,就是重骰一次囉!我先用.removeAll( ) 移除原本的 array,為了改變骰子位置所以 click + 1,再呼叫 generateDice( ) 生成新的一組骰子。

最後別忘了 present(alert, animated: true) ,做好的 Alert 才會出現~

//判斷順子開關是否開啟
if straightSwitch.isOn && isStraight(dice: diceArray){
//順子alert
let alert = UIAlertController(title: "順子!順子!", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "重骰", style: .default, handler: { [self] _ in
diceArray.removeAll()
click += 1
generateDice()
}))
present(alert, animated: true)

}else{
if isStraight(dice: diceArray){
print("straight")
}else{
print("not straight")
}
}

完整程式碼

import UIKit
import AVFoundation

class ViewController: UIViewController {
let oneDegree = CGFloat.pi/180
var click = 0
var diceArray = [Int]()
var player = AVPlayer()

@IBOutlet weak var straightSwitch: UISwitch!
@IBOutlet var diceCollection: [UIImageView]!


override func viewDidLoad() {
super.viewDidLoad()

}

//判斷順子function
func isStraight(dice:[Int])->Bool{
let sortedDiceArray = diceArray.sorted()
print(sortedDiceArray)

for i in 0..<sortedDiceArray.count - 1{
if sortedDiceArray[i] + 1 != sortedDiceArray[i + 1]{
return false
}
}
return true
}

//生成骰子function
func generateDice() {
//決定骰子位置樣式
switch click % 5{
case 1:
positionA()
case 2:
positionB()
case 3:
positionC()
case 4:
positionD()
default:
positioE()
}
//亂數生成骰子點數並作成array
for dice in diceCollection{
let randomDice = Int.random(in: 1...6)
dice.image = UIImage(named: "dice\(randomDice)")
diceArray.append(randomDice)
}
//骰子音效
let diceSoundUrl = Bundle.main.url(forResource: "diceSound", withExtension: "m4a")!
let playerItem = AVPlayerItem(url: diceSoundUrl)
player.replaceCurrentItem(with: playerItem)
player.play()
}


@IBAction func start(_ sender: UIButton) {
click += 1

diceArray.removeAll()
generateDice()
//判斷順子開關是否開啟
if straightSwitch.isOn && isStraight(dice: diceArray){
//順子alert
let alert = UIAlertController(title: "順子!順子!", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "重骰", style: .default, handler: { [self] _ in
diceArray.removeAll()
click += 1
generateDice()
}))
present(alert, animated: true)

}else{
if isStraight(dice: diceArray){
print("straight")
}else{
print("not straight")
}
}
}
}

APP Demo

後記

這次的練習,狀況最多的就是把設計圖刻出來的過程,我覺得應該會有更簡單的方式,雖然成品還是有被我生出來,我感覺自己弄得很繁複。程式的部分也感覺可以更精簡,可能需要更多時間思考如何把學到的寫法實際運用在練習裡🤔加油~

題外話,為了這個協作,我們還為這個小團隊取名,叫做 Saidio.co(賽到的台語)😎

作業參考

GitHub

--

--

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

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