iT邦幫忙

2021 iThome 鐵人賽

DAY 25
0
Mobile Development

雙平台APP小遊戲開發實作! Swift & Kotlin 攜手出擊~系列 第 25

[Day25] swift & kotlin 遊戲篇!(7) 小雞BB-遊戲製作-API與遊戲動畫

遊戲示意

Swift 匯入圖片

swift - 遊戲功能

接下來當我們點擊按鈕
我們來打個API 並告知道有沒有猜對
來看看API吧

API說明

請使用POST方式傳送資料,API會把結果告訴你

  • URL

    http://pinyi.ami-shake.com/gg_order.php

  • Method:

    POST

  • URL Params

    None

  • Data Params

    Required:

    choose=選擇的項目
    balance=目前的結餘的點數

  • Success Response:
    end even 代表走了四條橫線 odd代表三條

    • Code: 200
      Content: {"error_code":0,"error_msg":"","info":{"balance":"1200","is_win":true,"result":{"end":"even","stairs":"3","start":"left"}}}
  • Error Response:

    • Code: 200
      Content: {"error_code":10001,"error_msg":"Please POST 'choose' and 'balance' property","info":{"balance":"0","is_win":false,"result":{"end":"","stairs":"","start":""}}}

接下來撰寫按鈕點擊後的程式吧
首先產生一個Class來儲存分數
Swift 匯入圖片
Swift 匯入圖片
Swift 匯入圖片

此時根目錄下會跑出Player.swift
然後撰寫成這樣

import UIKit

class Player: NSObject {
    var point: Int = 1000
}

同樣的做法 我們還需要再一個
OrderResponse.swift

import UIKit

class OrderResponse: Decodable {
    let error_code: Int
    let error_msg: String
    let info: OrderInfoResponse
}

struct OrderInfoResponse: Decodable { // or Decodable
  let balance: String
  let is_win: Bool
  let result: ResultResponse
}
struct ResultResponse: Decodable { // or Decodable
    let end: String
    let stairs: String
    let start: String
}

Decodable 是用來解析API的JSON資料用的

此時回到 ViewController.swift
撰寫order方法, 按住 control 從按鈕分別拖拉到方法上

 // 按鈕
@IBOutlet weak var left_blue: UIButton!
@IBOutlet weak var right_blue: UIButton!
@IBOutlet weak var left_red: UIButton!
@IBOutlet weak var right_red: UIButton!

var player = Player()

@IBAction func choose(_ sender: UIButton) {
    // 使用 URLSession 打api
    let session = URLSession(configuration: .default)
    var request = URLRequest(url: URL(string: "http://pinyi.ami-shake.com/gg_order.php")!)
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpMethod = "POST"
    let data = ["choose": self.getChoose(sender), "balance": String(self.player.point)]
    
    do{
        request.httpBody = try JSONSerialization.data(withJSONObject: data, options: JSONSerialization.WritingOptions())
    }catch let error{
        print("passer data error")
        print(error)
    }
    
    session.dataTask(with: request) { data, response, error in
        if let data = data {
            do {
                let res = try JSONDecoder().decode(OrderResponse.self, from: data)
                執行動畫播放
                判斷輸贏 增減分數
            } catch let error {
                print("error")
                print(error)
            }
            
        }
    }.resume()
}

fileprivate func getChoose(_ sender: UIButton) -> String {
    if(sender == self.left_red){
        return "left_even"
    }
    if(sender == self.left_blue){
        return "left_odd"
    }
    if(sender == self.right_blue){
        return "right_odd"
    }
    if(sender == self.right_red){
        return "right_even"
    }
    
    return ""
}

接下來撰寫播放結果動畫的方法
API的 start 來決定打開雞蛋是左邊還是右邊
打開雞蛋後 判斷是否要顯示第四條線
然後讓雲朵隱藏起來
雞蛋依照線條開始跑
跑完之後更新分數
並重新讓動畫恢復播放前的狀態

enum EggWapperDirection {
    case Left
    case Right
}
enum HatColor {
    case Red
    case Blue
}
func playResult(_ eggWapperDirection: EggWapperDirection, _ hatColor: HatColor, _ isWin: Bool, _ newPoint: String)-> Void {
    var hasLastLine = true
    
    if(
        (eggWapperDirection == EggWapperDirection.Right && hatColor == HatColor.Blue) ||
        (eggWapperDirection == EggWapperDirection.Left && hatColor == HatColor.Red)
    ){
        // 這種情況下只有三條線
        hasLastLine = false
    }
    
    let eggshellAni =  self.openEggAni(eggWapperDirection)
    let cloudAni = self.displayCloud(false)
    let playEggAni = self.playEggAniOnLine(eggWapperDirection, hasLastLine)
    
    playEggAni.addCompletion({ _ in
        self.player.point = newPoint
        self.pointLabel.text = "Point: \(self.player.point)"
        self.reSetAni()
    })
    
    cloudAni.addCompletion({ _ in
        playEggAni.startAnimation()
    })
    
    displayLastLine(hasLastLine)
    
    eggshellAni.startAnimation()
    cloudAni.startAnimation()
    
}

func openEggAni(_ eggWapperDirection: EggWapperDirection) -> UIViewPropertyAnimator {
    return UIViewPropertyAnimator(duration: 0.5, curve: .linear, animations: {
        let egg: UIView! = eggWapperDirection == EggWapperDirection.Right ? self.eggshell_right : self.eggshell_left
        egg.transform = CGAffineTransform(translationX: 30, y: -30).rotated(by: 30 *  CGFloat.pi / 180 )
        egg.alpha = 0
    })
}

func displayCloud(_ isShow: Bool) -> UIViewPropertyAnimator{
    let CloudAni = UIViewPropertyAnimator(duration: isShow ? 0 : 1,curve: .linear, animations: {
        self.Cloud.alpha = isShow ? 1 : 0
    })
    
    return CloudAni
}

func playEggAniOnLine(_ eggWapperDirection: EggWapperDirection, _ hasLastLine: Bool) -> UIViewPropertyAnimator {
    let eggWapperAni = UIViewPropertyAnimator(duration: 3, curve: .linear)
    eggWapperAni.addAnimations {
        UIView.animateKeyframes(withDuration: 0, delay: 0, animations: {
            let eggRunLineKeyFrameOptions = self.getEggRunLineKeyFrameOptions(hasLastLine);
            for option in eggRunLineKeyFrameOptions {
                UIView.addKeyframe(
                    withRelativeStartTime: option.startTime,
                    relativeDuration: 0.1,
                    animations: {
                        if(eggWapperDirection == EggWapperDirection.Left){
                            self.eggWapperLeft.transform = CGAffineTransform(translationX: option.translationX, y: option.translationY)
                        } else {
                            self.eggWapperRight.transform = CGAffineTransform(translationX: -option.translationX, y: option.translationY)
                        }
                        
                    })
            }
        })
    }
    
    return eggWapperAni
}

fileprivate func getEggRunLineKeyFrameOptions(_ hasLastLine: Bool) -> Array<KeyFrameOptionItem>{
    var keyFrameOptions: Array<KeyFrameOptionItem> = []
    keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.0, translationX: 0, translationY: self.lineWapperHeight*0.2 + 50, rotated: 0, scaledX: 1.0))
    keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.1, translationX: self.lineWapperWidth, translationY:  self.lineWapperHeight*0.2 + 50, rotated: 0, scaledX: 1.0))
    keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.2, translationX: self.lineWapperWidth, translationY:  self.lineWapperHeight*0.4 + 50, rotated: 0, scaledX: 1.0))

    keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.3, translationX: 0, translationY:  self.lineWapperHeight*0.4 + 50, rotated: 0, scaledX:  1.0))
    keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.4, translationX: 0, translationY: self.lineWapperHeight*0.6 + 50, rotated: 0, scaledX:  1.0))
    keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.5, translationX: self.lineWapperWidth,  translationY: self.lineWapperHeight*0.6 + 50, rotated: 0, scaledX: 1.0))
    keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.6, translationX: self.lineWapperWidth, translationY:  self.lineWapperHeight*0.8 + 50, rotated: 0, scaledX:  1.0))
    if(hasLastLine){
        // 走第四條線
        keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.7, translationX: 0, translationY:  self.lineWapperHeight*0.8 + 50, rotated: 0, scaledX: 1.0))
        keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.8, translationX: 0, translationY:  self.lineWapperHeight*1 + 10, rotated: 0, scaledX:  1.0))
    } else {
        
        keyFrameOptions.append(KeyFrameOptionItem(startTime: 0.7, translationX: self.lineWapperWidth, translationY:  self.lineWapperHeight*1 + 10, rotated: 0, scaledX:  1.0))
    }
    
    return keyFrameOptions
}

func displayLastLine(_ isShow: Bool) {
    self.lastLineLayer?.isHidden = !isShow
    self.lastLineInLineLayer?.isHidden = !isShow
}

func reSetAni() -> Void {
    self.eggshell_left.transform = .identity
    self.eggshell_left.alpha = 1
    self.eggshell_right.transform = .identity
    self.eggshell_right.alpha = 1
    self.eggWapperLeft.transform = .identity
    self.eggWapperRight.transform = .identity
    
    self.displayCloud(true).startAnimation()
    self.displayLastLine(true)
    // 重新設定初始動畫
    self.setChickAnimation()
}

看起來程式碼有點多
但實際上就是設置各種動畫
讓他依序執行~ 完成
接下來為了避免打API時
用的人一直點按鈕 再加上一個方法

func enableAllButton(_ isEnable: Bool) -> Void {
    let buttonList = [self.left_red, self.left_blue , self.right_red, self.right_blue]
    let disableAlpha: CGFloat = 0.5
    
    for button in buttonList {
        button?.isEnabled = isEnable
        button?.alpha = isEnable ? 1 : disableAlpha
    }
}

然後當玩家獲勝時 我們幫他增加分數
並給他一個讚的圖案
輸的話扣分, 並給個倒讚的圖
我們再加上一些方法

@IBOutlet weak var pointLabel: UILabel!
@IBOutlet weak var winIcon: UIImageView!

func updatePoint(_ isWin: Bool, _ newPoint: String)-> Void {
    self.winIcon.isHidden = false
    if isWin {
        self.winIcon.image = UIImage(systemName: "hands.sparkles.fill")
    } else {
        self.winIcon.image = UIImage(systemName: "hand.thumbsdown")
    }
    
    self.updatePointAndDisplayInUI(Int(newPoint) ?? 0)
    
    self.checkIsGameOver()
}

func updatePointAndDisplayInUI(_ newPoint: Int) {
    self.player.point = newPoint
    self.pointLabel.text = "Point: \(self.player.point)"
}

func checkIsGameOver() -> Void {
    if self.player.point > 0 {
        return
    }
    
    self.alertMessage("遊戲結束!", "輸了! 遊戲即將重啟")
    self.updatePointAndDisplayInUI(1000)
}

func alertMessage(_ title: String,_ msg: String) -> Void {
    // 顯示提示訊息
    let alert = UIAlertController(title: title, message: msg, preferredStyle: .alert)
    let okBtn = UIAlertAction(title: "OK", style: .default, handler: nil)
    alert.addAction(okBtn)
    self.present(alert, animated: true, completion: nil)
}

整個遊戲終於做完了
明天來處裡遊戲紀錄摟!

kotlin - 遊戲功能

Kotlin也是要開始打API摟
再來複習一下API文件

API說明

請使用POST方式傳送資料,API會把結果告訴你

  • URL

    http://pinyi.ami-shake.com/gg_order.php

  • Method:

    POST

  • URL Params

    None

  • Data Params

    Required:

    choose=選擇的項目
    balance=目前的結餘的點數

  • Success Response:
    end even 代表走了四條橫線 odd代表三條

    • Code: 200
      Content: {"error_code":0,"error_msg":"","info":{"balance":"1200","is_win":true,"result":{"end":"even","stairs":"3","start":"left"}}}
  • Error Response:

    • Code: 200
      Content: {"error_code":10001,"error_msg":"Please POST 'choose' and 'balance' property","info":{"balance":"0","is_win":false,"result":{"end":"","stairs":"","start":""}}}

接下來撰寫按鈕點擊後的程式吧
kotlin這邊在資料儲存與傳遞上
給出了另一種解決方案叫做 ViewModel + LiveData

ViewModel + LiveData

其中的 ViewModel 主要就是用來管理資料並共享使用
而 LiveData 是讓資料產生 Lifecycle
從而可達到view與data之間的綁定

這種概念對寫前端的工程師來說並不陌生
前端前三大框架 Angular, Vue, React 也都是走這種設計
尤其是Angular 與 android 都是出自Google
所以兩個都是在MVVM框架下的系統

想使用 ViewModel的話 沒錯~
繼續去Gradle裡面去新增吧

添加依賴 進入build.gradle(Module:chick_bb.app)

dependencies {
    ... 很多東西

    // ViewModel
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'

    ... 很多東西
}

在com.test.chickbb底下新增一個package叫player
在com.test.chickbb底下新增一個package叫network
其實就是一個資料夾拉~

Kotlin 匯入圖片

然後在player底下新增一個kotlin Class 叫 PlayerViewModel
然後在network底下新增一個kotlin Class 叫 OrderResponse
首先解釋一下 OrderResponse
OrderResponse 裡面要定義等等打API回來的資料
內容這樣

package com.test.chickbb.network

import com.squareup.moshi.Json

data class OrderResponse (
    @Json(name = "error_code") var errorCode: String,
    @Json(name = "error_msg") var errorMsg: String,
    var info: OrderInfoResponse
)

data class OrderInfoResponse(
    var balance: String,
    @Json(name = "is_win") var isWin: Boolean,
    var result: ResultResponse,
)

data class ResultResponse(
    var end: String,
    var stairs: String,
    var start: String
)

PlayerViewModel 是儲存玩家點數
與遊戲紀錄的類別
內容這樣

package com.test.chickbb.player

import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.test.chickbb.network.ResultResponse

class PlayerViewModel() :  ViewModel()  {
    private var _currentPoint: Int
    private var _point  = MutableLiveData<Int>()
    val point: LiveData<Int> get() = _point
    val currentPoint: Int get() = _currentPoint

    private var _history = mutableListOf<OrderHistory>()
    val history: MutableList<OrderHistory> get() = _history

    init {
        Log.d("GameFragment", "GameViewModel created!")
        _point.value = 1000
        _currentPoint = 1000
    }

    override fun onCleared() {
        super.onCleared()
        Log.d("GameFragment", "GameViewModel destroyed!")
    }
    fun updatePoint(newPoint: Int) {
        this._point.value = newPoint
        this._currentPoint = newPoint
    }
    fun addHistory(choose: String, is_win: Boolean,newPoint: Int,result: ResultResponse){
        this._history.add(OrderHistory(choose, is_win, this._currentPoint, result, newPoint - this._currentPoint))
    }
}

class OrderHistory(
    var choose: String,
    var is_win: Boolean,
    var point: Int,
    var result: ResultResponse,
    var winPoint: Int)

所謂的 LiveData 其實就是透過觀察者模式
去訂閱 LiveData 變更的事件
每當資料變更時 就將UI進行更新

而為了方便程式使用 我另外加了 currentPoint
可以直接取得當下的Point
不用通過訂閱

此時回到 GameFragment.kt
撰寫order方法

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
    // Inflate the layout for this fragment
    _binding = FragmentGameBinding.inflate(inflater, container, false)
    // 為了資料共用 我們透過ViewModelProvider從Activity 取得實體
    player = ViewModelProvider(requireActivity()).get(PlayerViewModel::class.java)
    setChickAnimation()
    bindingBaseEvent()

    return binding.root
}

fun bindingBaseEvent() {
    // 執行動畫
    binding.root.doOnPreDraw {
        // kotlin 沒有生命週期綁定 視圖佈局完成
        // 所以用這邊綁定
        drawGameLine()
    }
    // 綁定按鈕事件
    binding.btnLeftBlue.setOnClickListener {
        choose(EggWapperDirection.Left, HatColor.Blue)
    }
    // 綁定按鈕事件
    binding.btnLeftRed.setOnClickListener {
        choose(EggWapperDirection.Left, HatColor.Red)
    }
    // 綁定按鈕事件
    binding.btnRightBlue.setOnClickListener {
        choose(EggWapperDirection.Right, HatColor.Blue)
    }
    // 綁定按鈕事件
    binding.btnRightRed.setOnClickListener {
        choose(EggWapperDirection.Right, HatColor.Red)
    }
    // 訂閱 LiveData事件
    player.point.observe(viewLifecycleOwner,
        { newPoint ->
            binding.pointLabel.text = "Point: " + newPoint.toString()
        })
}

LiveData的綁定就這樣
很簡單吧 ~ 只要值改變 就更新UI
個下來讓我們來看看choose 方法做了什麼

fun choose(eggWapperDirection: EggWapperDirection, hatColor: HatColor) {
    // 禁用所有按鈕
    this.enableAllButton(false)
    val chooseKey =  this.getChoose(eggWapperDirection, hatColor)
    // 打API與更新UI, 需要丟入後台線程處理  
    GlobalScope.launch {
        try {
            // 打API
            val result = MarsApi.retrofitService.order(chooseKey, player.currentPoint.toString())
            // 更新UI必須回主線程
            Handler(Looper.getMainLooper()).postDelayed({
                // 新增遊戲紀錄
                player.addHistory(chooseKey, result.info.isWin, result.info.balance.toInt(), result.info.result)
                // 播放動畫結果
                playResultFromResponse(result)
            }, 0)

        } catch (e: Exception) {
            println("error:"+e.message)

            // 啟動所有按鈕
            // 更新UI必須回主線程
            Handler(Looper.getMainLooper()).postDelayed({
                    enableAllButton(true)
            }, 0)
        }
    }
}
fun getChoose(eggWapperDirection: EggWapperDirection, hatColor: HatColor): String {
    if(eggWapperDirection == EggWapperDirection.Left && hatColor == HatColor.Red){
        return "left_even"
    }
    if(eggWapperDirection == EggWapperDirection.Left && hatColor == HatColor.Blue){
        return "left_odd"
    }
    if(eggWapperDirection == EggWapperDirection.Right && hatColor == HatColor.Blue){
        return "right_odd"
    }
    if(eggWapperDirection == EggWapperDirection.Right && hatColor == HatColor.Red){
        return "right_even"
    }

    return ""
}

打API這邊牽扯到三個知識點

  1. 打API (透過 Http)
  2. 後台線程
  3. 主線程 UI更新

一個一個來吧

1. 打API (Http Clinet)

Kotlin這邊對於Http Clinet 推薦採用第三方庫來完成
Http Clinet使用 Retrofit
JSON解析使用 Moshi
首先到gradle

dependencies {
    ... 很多東西

    // Retrofit with Moshi Converter
    implementation 'com.squareup.moshi:moshi-kotlin:1.9.3'
    implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'

    ... 很多東西
}

然後要開通網路權限
AndroidManifest.xml 加上

<uses-permission android:name="android.permission.INTERNET" />

接下來應為打的api是HTTP非HTTPS
所以我們要取消安全連線的限制
在res底下新增xml資料夾
xml下新增 network_security_config
內容如下

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>

然後在AndroidManifest.xml 的 application 內加上

 <application
        ...很多很多
        android:networkSecurityConfig="@xml/network_security_config"
        ...很多很多>
 

設定完長這樣

這樣才能開始寫Api的程式
在剛剛的network的資料夾內新增 kotlin Class
OrderApiService 檔案

package com.test.chickbb.network

import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
// api的 BASE_URL
private const val BASE_URL = "http://pinyi.ami-shake.com"
// 產生moshi JSON解析
private val moshi = Moshi.Builder()
                        .add(KotlinJsonAdapterFactory())
                        .build()

// 產生retrofit 並添加JSON解析器
private val retrofit = Retrofit.Builder()
    .addConverterFactory(MoshiConverterFactory.create(moshi))
    .baseUrl(BASE_URL)
    .build()

// 定義API  
// FormUrlEncoded代表解析對body進行 Encoded  
// POST(裡面是API路徑)
// suspend是限制他只能運作在線程中
interface OrderApiService {
    @FormUrlEncoded
    @POST("/gg_order.php")
    suspend fun order(@Field("choose") choose: String, @Field("balance") balance: String): OrderResponse
}

// 透過這個方法,達到單一實例
object OrderApi {
    val retrofitService : OrderApiService by lazy {
        retrofit.create(OrderApiService::class.java)
    }
}

此時只要 OrderApi.retrofitService.order("left_red", player.currentPoint.toString())
就可以取得資料了!

2. 後台線程

這邊要說明的是
應為API是非同步的, 你也不知道他要執行多久
所以必須把他交給子線程執行
GlobalScope.launch {}
可以產生一個後台線程 你不用去管理他
就可以丟出一個任務給他執行
整個程式也不會因為這個任務而卡住

3. 主線程 UI更新

當子線程打完api取得資料了
這是要進行UI的變更渲染
就要把任務交回主線程來更新畫面

Handler(Looper.getMainLooper()).postDelayed({
        更新UI任務()
}, 0)

比起swift, 需要了解更多知識點
接下來撰寫動畫吧

API的 start 來決定打開雞蛋是左邊還是右邊
打開雞蛋後 判斷是否要顯示第四條線
然後讓雲朵隱藏起來
雞蛋依照線條開始跑
跑完之後更新分數
並重新讓動畫恢復播放前的狀態

enum class EggWapperDirection {
    Left,
    Right
}
enum class HatColor {
    Red,
    Blue
}
fun playResultFromResponse(res: OrderResponse) {
    if(res.info.result.start == "left" && res.info.result.end == "odd" ){
        this.playResult(EggWapperDirection.Left, HatColor.Blue, res.info.isWin, res.info.balance)
    }
    if(res.info.result.start == "left" && res.info.result.end == "even" ){
        this.playResult(EggWapperDirection.Left, HatColor.Red, res.info.isWin, res.info.balance)
    }
    if(res.info.result.start == "right"  && res.info.result.end == "odd" ){
        this.playResult(EggWapperDirection.Right, HatColor.Blue, res.info.isWin, res.info.balance)
    }
    if(res.info.result.start == "right"  && res.info.result.end == "even" ){
        this.playResult(EggWapperDirection.Right, HatColor.Red, res.info.isWin, res.info.balance)
    }
}
fun playResult(eggWapperDirection: EggWapperDirection, hatColor: HatColor, isWin: Boolean, newPoint: String) {

    var hasLastLine = true

    if(
        (eggWapperDirection == EggWapperDirection.Right && hatColor == HatColor.Blue) ||
        (eggWapperDirection == EggWapperDirection.Left && hatColor == HatColor.Red)
    ){
        hasLastLine = false
    }

    val eggshellAni  = this.openEggAni(eggWapperDirection)
    val cloudAni = this.displayCloud(false)
    val playEggAni = this.playEggAniOnLine(eggWapperDirection, hasLastLine)

    playEggAni.doOnEnd {
        this.updatePoint(isWin, newPoint)
        this.reSetAni()
    }

    cloudAni.doOnEnd {
        playEggAni.apply {
            duration = 4000 // 動畫持續四秒
            start()  // 開始播放
        }
    }

    this.displayLastLine(hasLastLine)

    eggshellAni.apply {
        duration = 500
        start()
    }
    cloudAni.apply {
        duration = 1000
        start()
    }
}
private fun openEggAni(eggWapperDirection: EggWapperDirection): ObjectAnimator {
    val eggShell = if(eggWapperDirection == EggWapperDirection.Left)  binding.eggshellLeft else binding.eggshellRight

    // translationX
    val pvhtranslationX = PropertyValuesHolder.ofKeyframe("translationX",
        Keyframe.ofFloat(0f, 0f),
        Keyframe.ofFloat(1f, eggShell.width.toFloat()/2 )
    )

    // translationY
    val pvhtranslationY = PropertyValuesHolder.ofKeyframe("translationY",
        Keyframe.ofFloat(0f, 0f),
        Keyframe.ofFloat(1f, -(eggShell.width.toFloat())/2)
    )
    // rotation
    val pvhRotation = PropertyValuesHolder.ofKeyframe("rotation",
        Keyframe.ofFloat(0f, 10f),
        Keyframe.ofFloat(1f, 60f)
    )
    // rotation
    val pvhAlpha = PropertyValuesHolder.ofKeyframe("alpha",
        Keyframe.ofFloat(0f, 1f),
        Keyframe.ofFloat(1f, 0f)
    )
    // 設定 ggView 關鍵影格
    val ani = ObjectAnimator.ofPropertyValuesHolder(eggShell,
        pvhtranslationY,
        pvhtranslationX,
        pvhRotation,
        pvhAlpha)

    return ani

}
private fun displayCloud(isShow: Boolean): ObjectAnimator{
    return ObjectAnimator.ofPropertyValuesHolder(binding.cloud, PropertyValuesHolder.ofKeyframe("alpha",
        Keyframe.ofFloat(0f, binding.cloud.alpha),
        Keyframe.ofFloat(1f, if(isShow) 1f else 0f)
    ))
}
fun playEggAniOnLine(eggWapperDirection: EggWapperDirection, hasLastLine: Boolean): ObjectAnimator {
    val eggRunLineKeyFrameOptions = getEggRunLineKeyFrameOptions(hasLastLine, eggWapperDirection)
    val eggView = if (eggWapperDirection === EggWapperDirection.Left) binding.eggWapperLeft else binding.eggWapperRight

    return ObjectAnimator.ofPropertyValuesHolder(eggView, *eggRunLineKeyFrameOptions)
}
private fun  getEggRunLineKeyFrameOptions(hasLastLine: Boolean,direction: EggWapperDirection ): Array<PropertyValuesHolder>{
    val eggXOffset = if (direction === EggWapperDirection.Left) this.lineWapperWidth else -this.lineWapperWidth
    // translationX
    val pvhtranslationX = PropertyValuesHolder.ofKeyframe("translationX",
        Keyframe.ofFloat(0f, 0f),
        Keyframe.ofFloat(.1f, 0f),
        Keyframe.ofFloat(.2f, eggXOffset),
        Keyframe.ofFloat(.3f, eggXOffset),
        Keyframe.ofFloat(.4f, 0f),
        Keyframe.ofFloat(.5f, 0f),
        Keyframe.ofFloat(.6f, eggXOffset),
        Keyframe.ofFloat(.7f, eggXOffset),
        Keyframe.ofFloat(.8f, if(hasLastLine) 0f else eggXOffset),
        Keyframe.ofFloat(.9f, if(hasLastLine) 0f else eggXOffset),
        Keyframe.ofFloat(1f, if(hasLastLine) 0f else eggXOffset)
    )
    val eggYOffset = (binding.eggLeft.height/3)
    val finalYOffset = (binding.eggLeft.height/4)

    val pvhtranslationY = PropertyValuesHolder.ofKeyframe("translationY",
        Keyframe.ofFloat(0f, 0f),
        Keyframe.ofFloat(.1f, this.lineWapperHeight*0.2f + eggYOffset),
        Keyframe.ofFloat(.2f, this.lineWapperHeight*0.2f + eggYOffset),
        Keyframe.ofFloat(.3f, this.lineWapperHeight*0.4f + eggYOffset),
        Keyframe.ofFloat(.4f, this.lineWapperHeight*0.4f + eggYOffset),
        Keyframe.ofFloat(.5f, this.lineWapperHeight*0.6f + eggYOffset),
        Keyframe.ofFloat(.6f, this.lineWapperHeight*0.6f + eggYOffset),
        Keyframe.ofFloat(.7f, this.lineWapperHeight*0.8f + eggYOffset),
        Keyframe.ofFloat(.8f, if(hasLastLine) this.lineWapperHeight*0.8f + eggYOffset else this.lineWapperHeight*1f + finalYOffset),
        Keyframe.ofFloat(.9f, this.lineWapperHeight*1f + finalYOffset),
        Keyframe.ofFloat(1f, this.lineWapperHeight*1f + finalYOffset)
    )

    return arrayOf(pvhtranslationX,pvhtranslationY)

}
fun displayLastLine(isShow: Boolean) {
    this.lastLineImageView?.alpha = if (isShow)  1f else 0f
}
fun reSetAni() {
    val resetViews = listOf<View>(binding.eggshellLeft, binding.eggshellRight, binding.eggWapperRight, binding.eggWapperLeft)
    for (vi in resetViews) {
        vi.translationX = 0f
        vi.translationY = 0f
        vi.rotation = 0f
        vi.alpha = 1f
    }

    this.displayCloud(true).apply {
        duration = 0
        start()
    }
    this.displayLastLine(true)
    this.enableAllButton(true)
}

看起來程式碼有點多
但實際上就是設置各種動畫
讓他依序執行~ 完成
接下來為了避免打API時
用的人一直點按鈕 再加上一個方法

fun enableAllButton(isEnable: Boolean) {
    val buttonList = listOf(binding.btnLeftRed, binding.btnLeftBlue,  binding.btnRightRed, binding.btnRightBlue)
    buttonList.forEach {
        it.isEnabled = isEnable
        it.alpha = if (isEnable) 1f else 0.5f
    }

}

然後當玩家獲勝時 我們幫他增加分數
並給他一個讚的圖案
輸的話扣分, 並給個倒讚的圖
我們再加上一些方法

fun updatePoint(isWin: Boolean, newPoint: String) {
    binding.winIcon.alpha = 1f
    if (isWin) {
        binding.winIcon.setImageResource(android.R.drawable.stat_sys_upload)
    } else {
        binding.winIcon.setImageResource(android.R.drawable.stat_sys_download)
    }

    this.updatePointAndDisplayInUI(newPoint.toInt())

}

fun updatePointAndDisplayInUI(newPoint: Int) {
    if(newPoint > 0){
        player.updatePoint(newPoint)
    } else {
        this.isGameOver()
    }
}

fun isGameOver() {
    this.alertMessage("遊戲結束!", "輸了! 遊戲即將重啟")
    this.player.updatePoint(1000);
}

fun alertMessage (title: String,msg: String) {
    AlertDialog.Builder(binding.root.context)
        .setMessage(msg)
        .setTitle(title)
        .setPositiveButton("OK", null)
        .show()
}

整個遊戲終於做完了

差異

Kotlin 在這個章節裡面
需要知道非常多的知識點
尤其打HTTP連線 還要選擇使用的第三方庫

這點在Swift就比較統一
你也不用特別選 就一個方法

但Kotlin好處就是 可以選擇你比較習慣的用法
不同第三方庫 使用起來的方便度也是有差的
這邊採用的是官方教學文件的用法

給大家參考看看摟

小碎嘴時間 ヽ(゚´Д`)ノ゚

哇賽~今天資訊大爆炸
內容超多的

剩下最後五天~還有程式收尾與APP上架
加油加油~終點快到摟~


上一篇
[Day24] swift & kotlin 遊戲篇!(6) 小雞BB-遊戲製作-線條繪製與彈跳動畫
下一篇
[Day26] swift & kotlin 遊戲篇!(8) 小雞BB-遊戲製作-歷史紀錄
系列文
雙平台APP小遊戲開發實作! Swift & Kotlin 攜手出擊~30

尚未有邦友留言

立即登入留言