Android 手擼抖音“潛艇大挑戰”,下一個爆款遊戲可能由你開發

作者:fundroid_方卓

鏈接:https://blog.csdn.net/vitaviva/article/details/105613652

《潛水艇大挑戰》是抖音上的一款小遊戲,以面部識別來驅動潛艇通過障礙物,最近特別火爆,相信很多人都玩過。

Android 手擼抖音“潛艇大挑戰”,下一個爆款遊戲可能由你開發

一時興起自己用Android自定義View也擼了一個,發現只要有好的創意,不用高深的技術照樣可以開發出好玩的應用。開發過程現拿出來與大家分享一下。

項目地址:

https://github.com/vitaviva/ugame

需要學習的內容

NDK模塊開發

音視頻的開發,往往是比較難的,而這個比較難的技術就是NDK裏面的技術。音視頻/高清大圖片/人工智能/直播/抖音等等這年與用戶最緊密,與我們生活最相關的技術一直都在尋找最終的技術落地平臺,以前是windows系統,而現在則是移動系統了,移動系統中又是以Android佔比絕大部分爲前提,所以AndroidNDK技術已經是我們必備技能了。要學習好NDK,其中的關於C/C++,jni,Linux基礎都是需要學習的,除此之外,音視頻的編解碼技術,流媒體協議,ffmpeg這些都是音視頻開發必備技能,而且OpenCV/OpenGl/這些又是圖像處理必備知識,這些都是需要學習的。

需要下面資料視頻的可以私信我【進階】我免費分享給你,或者直接點擊下面鏈接就可領取希望對大家有幫助。

Android學習PDF+架構視頻+面試文檔+源碼筆記

如果你有其他需要的話,也可以在 GitHub 上查看,下面的資料也會陸續上傳到Github,你可以按照我的學習路線,對比一下你自己的,這個就是查漏補缺的一個過程。

Android 手擼抖音“潛艇大挑戰”,下一個爆款遊戲可能由你開發

學習視頻

Android 手擼抖音“潛艇大挑戰”,下一個爆款遊戲可能由你開發

基本思路

整個遊戲視圖可以分成三層:

  • camera(相機):處理相機的preview以及人臉識別
  • background(後景):處理障礙物相關邏輯
  • foreground(前景):處理潛艇相關

Android 手擼抖音“潛艇大挑戰”,下一個爆款遊戲可能由你開發

代碼也是按上面三個層面組織的,遊戲界面的佈局可以簡單理解爲三層視圖的疊加,然後在各層視圖中完成相關工作

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

  <!-- 相機 -->
  <TextureView
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

  <!-- 後景 -->
  <com.my.ugame.bg.BackgroundView
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

  <!-- 前景 -->
  <com.my.ugame.fg.ForegroundView
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</Framelayout>

開發中會涉及以下技術的使用,沒有高精尖、都是大路貨:

  • 相機:使用Camera2完成相機的預覽和人臉識別
  • 自定義View:定義並控制障礙物和潛艇
  • 屬性動畫:控制障礙物和潛艇的移動及各種動效

少囉嗦,先看東西!下面介紹各部分代碼的實現。

2、後景(Background)

Bar

首先定義障礙物基類Bar,主要負責是將bitmap資源繪製到指定區域。由於障礙物從屏幕右側定時刷新時的高度隨機,所以其繪製區域的x、y、w、h需要動態設置

/**
 * 屏幕下方障礙物
 */
class DnBar(context: Context, container: ViewGroup) : Bar(context) {

    override val bmp = super.bmp.let {
        Bitmap.createBitmap(
            it, 0, 0, it.width, it.height,
            Matrix().apply { postRotate(-180F) }, true
        )
    }

    private val _srcRect by lazy(LazyThreadSafetyMode.NONE) {
        Rect(0, 0, bmp.width, (bmp.height * (h / container.height)).toInt())
    }

    override val srcRect: Rect
        get() = _srcRect
}

障礙物分爲上方和下方兩種,由於使用了同一張資源,所以繪製時要區別對待,因此定義了兩個子類:UpBar和DnBar

/**
 * 屏幕下方障礙物
 */
class DnBar(context: Context, container: ViewGroup) : Bar(context) {

    override val bmp = super.bmp.let {
        Bitmap.createBitmap(
            it, 0, 0, it.width, it.height,
            Matrix().apply { postRotate(-180F) }, true
        )
    }

    private val _srcRect by lazy(LazyThreadSafetyMode.NONE) {
        Rect(0, 0, bmp.width, (bmp.height * (h / container.height)).toInt())
    }

    override val srcRect: Rect
        get() = _srcRect
}

下方障礙物的資源旋轉180度後繪製

/**
 * 屏幕下方障礙物
 */
class DnBar(context: Context, container: ViewGroup) : Bar(context) {

    override val bmp = super.bmp.let {
        Bitmap.createBitmap(
            it, 0, 0, it.width, it.height,
            Matrix().apply { postRotate(-180F) }, true
        )
    }

    private val _srcRect by lazy(LazyThreadSafetyMode.NONE) {
        Rect(0, 0, bmp.width, (bmp.height * (h / container.height)).toInt())
    }

    override val srcRect: Rect
        get() = _srcRect
}

BackgroundView

接下來創建後景的容器BackgroundView,容器用來定時地創建、並移動障礙物。

通過列表barsList管理當前所有的障礙物,onLayout中,將障礙物分別佈局到屏幕上方和下方

/**
 * 後景容器類
 */
class BackgroundView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) {

    internal val barsList = mutableListOf<Bars>()

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        barsList.flatMap { listOf(it.up, it.down) }.forEach {
            val w = it.view.measuredWidth
            val h = it.view.measuredHeight
            when (it) {
                is UpBar -> it.view.layout(0, 0, w, h)
                else -> it.view.layout(0, height - h, w, height)
            }
        }
    }

提供兩個方法start和stop,控制遊戲的開始和結束:

  • 遊戲結束時,要求所有障礙物停止移動。
  • 遊戲開始後會通過Timer,定時刷新障礙物
    /**
     * 遊戲結束,停止所有障礙物的移動
     */
    @UiThread
    fun stop() {
        _timer.cancel()
        _anims.forEach { it.cancel() }
        _anims.clear()
    }

    /**
     * 定時刷新障礙物:
     * 1. 創建
     * 2. 添加到視圖
     * 3. 移動
     */
    @UiThread
    fun start() {
        _clearBars()
        Timer().also { _timer = it }.schedule(object : TimerTask() {
            override fun run() {
                post {
                    _createBars(context, barsList.lastOrNull()).let {
                        _addBars(it)
                        _moveBars(it)
                    }
                }
            }

        },  FIRST_APPEAR_DELAY_MILLIS, BAR_APPEAR_INTERVAL_MILLIS
        )
    }

     /**
     * 遊戲重啓時,清空障礙物
     */
    private fun _clearBars() {
        barsList.clear()
        removeAllViews()
    }

刷新障礙物

障礙物的刷新經歷三個步驟:

  1. 創建:上下兩個爲一組創建障礙物
  2. 添加:將對象添加到barsList,同時將View添加到容器
  3. 移動:通過屬性動畫從右側移動到左側,並在移出屏幕後刪除

創建障礙物時會爲其設置隨機高度,隨機不能太過,要以前一個障礙物爲基礎進行適當調整,保證隨機的同時兼具連貫性

 /**
 * 創建障礙物(上下兩個爲一組)
 */
private fun _createBars(context: Context, pre: Bars?) = run {
    val up = UpBar(context, this).apply {
        h = pre?.let {
            val step = when {
                it.up.h >= height - _gap - _step -> -_step
                it.up.h <= _step -> _step
                _random.nextBoolean() -> _step
                else -> -_step
            }
            it.up.h + step
        } ?: _barHeight
        w = _barWidth
    }

    val down = DnBar(context, this).apply {
        h = height - up.h - _gap
        w = _barWidth
    }

    Bars(up, down)

}

/**
 * 添加到屏幕
 */
private fun _addBars(bars: Bars) {
    barsList.add(bars)
    bars.asArray().forEach {
        addView(
            it.view,
            ViewGroup.LayoutParams(
                it.w.toInt(),
                it.h.toInt()
            )
        )
    }
}

/**
 * 使用屬性動畫移動障礙物
 */
private fun _moveBars(bars: Bars) {
    _anims.add(
        ValueAnimator.ofFloat(width.toFloat(), -_barWidth)
            .apply {
                addUpdateListener {
                    bars.asArray().forEach { bar ->
                        bar.x = it.animatedValue as Float
                        if (bar.x + bar.w <= 0) {
                            post { removeView(bar.view) }
                        }
                    }
                }

                duration = BAR_MOVE_DURATION_MILLIS
                interpolator = LinearInterpolator()
                start()
            })
}

}

3、前景(Foreground)

Boat

定義潛艇類Boat,創建自定義View,並提供方法移動到指定座標

 /**
 * 潛艇類
 */
class Boat(context: Context) {

    internal val view by lazy { BoatView(context) }

    val h
        get() = view.height.toFloat()

    val w
        get() = view.width.toFloat()

    val x
        get() = view.x

    val y
        get() = view.y

    /**
     * 移動到指定座標
     */
    fun moveTo(x: Int, y: Int) {
        view.smoothMoveTo(x, y)
    }

}

BoatView

自定義View中完成以下幾個事情

  • 通過兩個資源定時切換,實現探照燈閃爍的效果
  • 通過OverScroller讓移動過程更加順滑
  • 通過一個Rotation Animation,讓潛艇在移動時可以調轉角度,更加靈動
internal class BoatView(context: Context?) : AppCompatImageView(context) {

    private val _scroller by lazy { OverScroller(context) }

    private val _res = arrayOf(
        R.mipmap.boat_000,
        R.mipmap.boat_002
    )

    private var _rotationAnimator: ObjectAnimator? = null

    private var _cnt = 0
        set(value) {
            field = if (value > 1) 0 else value
        }

    init {
        scaleType = ScaleType.FIT_CENTER
        _startFlashing()
    }

    private fun _startFlashing() {
        postDelayed({
            setImageResource(_res[_cnt++])
            _startFlashing()
        }, 500)
    }

    override fun computeScroll() {
        super.computeScroll()

        if (_scroller.computeScrollOffset()) {

            x = _scroller.currX.toFloat()
            y = _scroller.currY.toFloat()

            // Keep on drawing until the animation has finished.
            postInvalidateOnAnimation()
        }

    }

    /**
     * 移動更加順換
     */
    internal fun smoothMoveTo(x: Int, y: Int) {
        if (!_scroller.isFinished) _scroller.abortAnimation()
        _rotationAnimator?.let { if (it.isRunning) it.cancel() }

        val curX = this.x.toInt()
        val curY = this.y.toInt()

        val dx = (x - curX)
        val dy = (y - curY)
        _scroller.startScroll(curX, curY, dx, dy, 250)

        _rotationAnimator = ObjectAnimator.ofFloat(
            this,
            "rotation",
            rotation,
            Math.toDegrees(atan((dy / 100.toDouble()))).toFloat()
        ).apply {
            duration = 100
            start()
        }

        postInvalidateOnAnimation()
    }
}

ForegroundView

  • 通過boat成員持有潛艇對象,並對其進行控制
  • 實現CameraHelper.FaceDetectListener根據人臉識別的回調,移動潛艇到指定位置
  • 遊戲開始時,創建潛艇並做開場動畫
 /**
     * 遊戲開始時通過動畫進入
     */
    @MainThread
    fun start() {
        _isStop = false
        if (boat == null) {
            boat = Boat(context).also {
                post {
                    addView(it.view, _width, _width)
                    AnimatorSet().apply {
                        play(
                            ObjectAnimator.ofFloat(
                                it.view,
                                "y",
                                0F,
                                [email protected] / 2f
                            )
                        ).with(
                            ObjectAnimator.ofFloat(it.view, "rotation", 0F, 360F)
                        )
                        doOnEnd { _ -> it.view.rotation = 0F }
                        duration = 1000
                    }.start()
                }
            }
        }
    }

開場動畫

遊戲開始時,將潛艇通過動畫移動到起始位置,即y軸的二分之一處

 /**
     * 遊戲開始時通過動畫進入
     */
    @MainThread
    fun start() {
        _isStop = false
        if (boat == null) {
            boat = Boat(context).also {
                post {
                    addView(it.view, _width, _width)
                    AnimatorSet().apply {
                        play(
                            ObjectAnimator.ofFloat(
                                it.view,
                                "y",
                                0F,
                                [email protected] / 2f
                            )
                        ).with(
                            ObjectAnimator.ofFloat(it.view, "rotation", 0F, 360F)
                        )
                        doOnEnd { _ -> it.view.rotation = 0F }
                        duration = 1000
                    }.start()
                }
            }
        }
    }

4、相機(Camera)

相機部分主要有TextureView和CameraHelper組成。TextureView提供給Camera承載preview;工具類CameraHelper主要完成以下功能:

  • 開啓相機:通過CameraManger打開攝像頭
  • 攝像頭切換:切換前後置攝像頭,
  • 預覽:獲取Camera提供的可預覽尺寸,並適配TextureView顯示
  • 人臉識別:檢測人臉位置,進行TestureView上的座標變換

適配PreviewSize

相機硬件提供的可預覽尺寸與屏幕實際尺寸(即TextureView尺寸)可能不一致,所以需要在相機初始化時,選取最合適的PreviewSize,避免TextureView上發生畫面拉伸等異常

class CameraHelper(val mActivity: Activity, private val mTextureView: TextureView) {

    private lateinit var mCameraManager: CameraManager
    private var mCameraDevice: CameraDevice? = null
    private var mCameraCaptureSession: CameraCaptureSession? = null

    private var canExchangeCamera = false                                               //是否可以切換攝像頭
    private var mFaceDetectMatrix = Matrix()                                            //人臉檢測座標轉換矩陣
    private var mFacesRect = ArrayList<RectF>()                                         //保存人臉座標信息
    private var mFaceDetectListener: FaceDetectListener? = null                         //人臉檢測回調
    private lateinit var mPreviewSize: Size

    /**
     * 初始化
     */
    private fun initCameraInfo() {
        mCameraManager = mActivity.getSystemService(Context.CAMERA_SERVICE) as CameraManager
        val cameraIdList = mCameraManager.cameraIdList
        if (cameraIdList.isEmpty()) {
            mActivity.toast("沒有可用相機")
            return
        }

        //獲取攝像頭方向
        mCameraSensorOrientation =
            mCameraCharacteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!!
        //獲取StreamConfigurationMap,它是管理攝像頭支持的所有輸出格式和尺寸
        val configurationMap =
            mCameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!

        val previewSize = configurationMap.getOutputSizes(SurfaceTexture::class.java) //預覽尺寸

        // 當屏幕爲垂直的時候需要把寬高值進行調換,保證寬大於高
        mPreviewSize = getBestSize(
            mTextureView.height,
            mTextureView.width,
            previewSize.toList()
        )

        //根據preview的size設置TextureView
        mTextureView.surfaceTexture.setDefaultBufferSize(mPreviewSize.width, mPreviewSize.height)
        mTextureView.setAspectRatio(mPreviewSize.height, mPreviewSize.width)
    }

選取preview尺寸的原則與TextureView的長寬比儘量一致,且面積儘量接近。

initFaceDetect()用來進行人臉的Matrix初始化,後文介紹。

人臉識別

爲相機預覽,創建一個CameraCaptureSession對象,會話通過CameraCaptureSession.CaptureCallback返回TotalCaptureResult,通過參數可以讓其中包括人臉識別的相關信息

 /**
     * 創建預覽會話
     */
    private fun createCaptureSession(cameraDevice: CameraDevice) {

        // 爲相機預覽,創建一個CameraCaptureSession對象
        cameraDevice.createCaptureSession(
            arrayListOf(surface),
            object : CameraCaptureSession.StateCallback() {

                override fun onConfigured(session: CameraCaptureSession) {
                    mCameraCaptureSession = session
                    session.setRepeatingRequest(
                        captureRequestBuilder.build(),
                        mCaptureCallBack,
                        mCameraHandler
                    )
                }

            },
            mCameraHandler
        )
    }

    private val mCaptureCallBack = object : CameraCaptureSession.CaptureCallback() {
        override fun onCaptureCompleted(
            session: CameraCaptureSession,
            request: CaptureRequest,
            result: TotalCaptureResult
        ) {
            super.onCaptureCompleted(session, request, result)
            if (mFaceDetectMode != CaptureRequest.STATISTICS_FACE_DETECT_MODE_OFF)
                handleFaces(result)

        }
    }

通過mFaceDetectMatrix對人臉信息進行矩陣變化,確定人臉座標以使其準確應用到TextureView。

  /**
     * 處理人臉信息
     */
    private fun handleFaces(result: TotalCaptureResult) {
        val faces = result.get(CaptureResult.STATISTICS_FACES)!!
        mFacesRect.clear()

        for (face in faces) {
            val bounds = face.bounds

            val left = bounds.left
            val top = bounds.top
            val right = bounds.right
            val bottom = bounds.bottom

            val rawFaceRect =
                RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())
            mFaceDetectMatrix.mapRect(rawFaceRect)

            var resultFaceRect = if (mCameraFacing == CaptureRequest.LENS_FACING_FRONT) {
                rawFaceRect
            } else {
                RectF(
                    rawFaceRect.left,
                    rawFaceRect.top - mPreviewSize.width,
                    rawFaceRect.right,
                    rawFaceRect.bottom - mPreviewSize.width
                )
            }

            mFacesRect.add(resultFaceRect)

        }

          mActivity.runOnUiThread {
            mFaceDetectListener?.onFaceDetect(faces, mFacesRect)
        }
    }

最後,在UI線程將包含人臉座標的Rect通過回調傳出:

mActivity.runOnUiThread {
        mFaceDetectListener?.onFaceDetect(faces, mFacesRect)
    }

FaceDetectMatrix

mFaceDetectMatrix是在獲取PreviewSize之後創建的

    /**
     * 初始化人臉檢測相關信息
     */
    private fun initFaceDetect() {

        val faceDetectModes =
            mCameraCharacteristics.get(CameraCharacteristics.STATISTICS_INFO_AVAILABLE_FACE_DETECT_MODES)  //人臉檢測的模式

        mFaceDetectMode = when {
            faceDetectModes!!.contains(CaptureRequest.STATISTICS_FACE_DETECT_MODE_FULL) -> CaptureRequest.STATISTICS_FACE_DETECT_MODE_FULL
            faceDetectModes!!.contains(CaptureRequest.STATISTICS_FACE_DETECT_MODE_SIMPLE) -> CaptureRequest.STATISTICS_FACE_DETECT_MODE_FULL
            else -> CaptureRequest.STATISTICS_FACE_DETECT_MODE_OFF
        }

        if (mFaceDetectMode == CaptureRequest.STATISTICS_FACE_DETECT_MODE_OFF) {
            mActivity.toast("相機硬件不支持人臉檢測")
            return
        }

        val activeArraySizeRect =
            mCameraCharacteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)!! //獲取成像區域
        val scaledWidth = mPreviewSize.width / activeArraySizeRect.width().toFloat()
        val scaledHeight = mPreviewSize.height / activeArraySizeRect.height().toFloat()

        val mirror = mCameraFacing == CameraCharacteristics.LENS_FACING_FRONT

        mFaceDetectMatrix.setRotate(mCameraSensorOrientation.toFloat())
        mFaceDetectMatrix.postScale(if (mirror) -scaledHeight else scaledHeight, scaledWidth)// 注意交換width和height的位置!
        mFaceDetectMatrix.postTranslate(
            mPreviewSize.height.toFloat(),
            mPreviewSize.width.toFloat()
        )

    }

5、控制類(GameController)

三大視圖層組裝完畢,最後需要一個總控類,對遊戲進行邏輯控制

主要完成以下工作:

  • 控制遊戲的開啓/停止
  • 計算遊戲的當前得分
  • 檢測潛艇的碰撞
  • 對外(Activity或者Fragment等)提供遊戲狀態監聽的接口

初始化

遊戲開始時進行相機的初始化,創建GameHelper類並建立setFaceDetectListener回調到ForegroundView

class GameController(
    private val activity: AppCompatActivity,
    private val textureView: AutoFitTextureView,
    private val bg: BackgroundView,
    private val fg: ForegroundView
) {

    private var camera2HelperFace: CameraHelper? = null
    /**
     * 相機初始化
     */
    private fun initCamera() {
        cameraHelper ?: run {
            cameraHelper = CameraHelper(activity, textureView).apply {
                setFaceDetectListener(object : CameraHelper.FaceDetectListener {
                    override fun onFaceDetect(faces: Array<Face>, facesRect: ArrayList<RectF>) {
                        if (facesRect.isNotEmpty()) {
                            fg.onFaceDetect(faces, facesRect)
                        }
                    }
                })
            }
        }
    }

遊戲狀態

定義GameState,對外提供狀態的監聽。目前支持三種狀態

  • Start:遊戲開始
  • Over:遊戲結束
  • Score:遊戲得分
sealed class GameState(open val score: Long) {
    object Start : GameState(0)
    data class Over(override val score: Long) : GameState(score)
    data class Score(override val score: Long) : GameState(score)
}

可以在stop、start的時候,更新狀態

    /**
     * 遊戲狀態
     */
    private val _state = MutableLiveData<GameState>()
    internal val gameState: LiveData<GameState>
        get() = _state

    /**
     * 遊戲停止
     */
    fun stop() {
        bg.stop()
        fg.stop()
        _state.value = GameState.Over(_score)
        _score = 0L
    }

    /**
     * 遊戲開始
     */
    fun start() {
        initCamera()
        fg.start()
        bg.start()
        _state.value = GameState.Start
        handler.postDelayed({
            startScoring()
        }, FIRST_APPEAR_DELAY_MILLIS)
    }

計算得分

遊戲啓動時通過startScoring開始計算得分並通過GameState上報。

目前的規則設置很簡單,存活時間即遊戲得分

    /**
     * 開始計分
     */
    private fun startScoring() {
        handler.postDelayed(
            {
                fg.boat?.run {
                    bg.barsList.flatMap { listOf(it.up, it.down) }
                        .forEach { bar ->
                            if (isCollision(
                                    bar.x, bar.y, bar.w, bar.h,
                                    this.x, this.y, this.w, this.h
                                )
                            ) {
                                stop()
                                return@postDelayed
                            }
                        }
                }
                _score++
                _state.value = GameState.Score(_score)
                startScoring()
            }, 100
        )
    }

檢測碰撞

isCollision根據潛艇和障礙物當前位置,計算是否發生了碰撞,發生碰撞則GameOver

/**
 * 碰撞檢測
 */
private fun isCollision(
    x1: Float,
    y1: Float,
    w1: Float,
    h1: Float,
    x2: Float,
    y2: Float,
    w2: Float,
    h2: Float
): Boolean {
    if (x1 > x2 + w2 || x1 + w1 < x2 || y1 > y2 + h2 || y1 + h1 < y2) {
        return false
    }
    return true
}

6、Activity

Activity的工作簡單:

  • 權限申請:動態申請Camera權限
  • 監聽遊戲狀態:創建GameController,並監聽GameState狀態
private fun startGame() {
    PermissionUtils.checkPermission(this, Runnable {
        gameController.start()
        gameController.gameState.observe(this, Observer {
            when (it) {
                is GameState.Start ->
                    score.text = "DANGER\nAHEAD"
                is GameState.Score ->
                    score.text = "${it.score / 10f} m"
                is GameState.Over ->
                    AlertDialog.Builder(this)
                        .setMessage("遊戲結束!成功推進 ${it.score / 10f} 米! ")
                        .setNegativeButton("結束遊戲") { _: DialogInterface, _: Int ->
                            finish()
                        }.setCancelable(false)
                        .setPositiveButton("再來一把") { _: DialogInterface, _: Int ->
                            gameController.start()
                        }.show()
            }
        })
    })
}

最後

項目結構很清晰,用到的大都是常規技術,即使是新入坑Android的同學看起來也不費力。在現有基礎上還可以通過添加BGM、增加障礙物種類等,進一步提高遊戲性。

喜歡的話留個star鼓勵一下作者吧 ^^

https://github.com/vitaviva/ugame

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章