Android 實(shí)現(xiàn)抖音小游戲潛艇大挑戰(zhàn)的思路詳解
《潛水艇大挑戰(zhàn)》是抖音上的一款小游戲,以面部識別來驅(qū)動(dòng)潛艇通過障礙物,最近特別火爆,相信很多人都玩過。
一時(shí)興起自己用Android自定義View也擼了一個(gè),發(fā)現(xiàn)只要有好的創(chuàng)意,不用高深的技術(shù)照樣可以開發(fā)出好玩的應(yīng)用。開發(fā)過程現(xiàn)拿出來與大家分享一下。
項(xiàng)目地址:
https://github.com/vitaviva/ugame
基本思路
整個(gè)游戲視圖可以分成三層:
camera(相機(jī)):處理相機(jī)的preview以及人臉識別 background(后景):處理障礙物相關(guān)邏輯 foreground(前景):處理潛艇相關(guān)代碼也是按上面三個(gè)層面組織的,游戲界面的布局可以簡單理解為三層視圖的疊加,然后在各層視圖中完成相關(guān)工作
<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'> <!-- 相機(jī) --> <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>
開發(fā)中會(huì)涉及以下技術(shù)的使用,沒有高精尖、都是大路貨:
相機(jī):使用Camera2完成相機(jī)的預(yù)覽和人臉識別 自定義View:定義并控制障礙物和潛艇 屬性動(dòng)畫:控制障礙物和潛艇的移動(dòng)及各種動(dòng)效少??攏?瓤炊?鰨∠旅娼檣芨韃糠執(zhí)?氳氖迪幀?/p>
后景(Background)Bar
首先定義障礙物基類Bar,主要負(fù)責(zé)是將bitmap資源繪制到指定區(qū)域。由于障礙物從屏幕右側(cè)定時(shí)刷新時(shí)的高度隨機(jī),所以其繪制區(qū)域的x、y、w、h需要?jiǎng)討B(tài)設(shè)置
/** * 障礙物基類 */sealed class Bar(context: Context) { protected open val bmp = context.getDrawable(R.mipmap.bar)!!.toBitmap() protected abstract val srcRect: Rect private lateinit var dstRect: Rect private val paint = Paint() var h = 0F set(value) { field = value dstRect = Rect(0, 0, w.toInt(), h.toInt()) } var w = 0F set(value) { field = value dstRect = Rect(0, 0, w.toInt(), h.toInt()) } var x = 0F set(value) { view.x = value field = value } val y get() = view.y internal val view by lazy { BarView(context) { it?.apply { drawBitmap( bmp, srcRect, dstRect, paint ) } } }}internal class BarView(context: Context?, private val block: (Canvas?) -> Unit) : View(context) { override fun onDraw(canvas: Canvas?) { block((canvas)) }}
障礙物分為上方和下方兩種,由于使用了同一張資源,所以繪制時(shí)要區(qū)別對待,因此定義了兩個(gè)子類:UpBar和DnBar
/** * 屏幕上方障礙物 */class UpBar(context: Context, container: ViewGroup) : Bar(context) { private val _srcRect by lazy(LazyThreadSafetyMode.NONE) { Rect(0, (bmp.height * (1 - (h / container.height))).toInt(), bmp.width, bmp.height) } override val srcRect: Rect get() = _srcRect}
下方障礙物的資源旋轉(zhuǎn)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
接下來創(chuàng)建后景的容器BackgroundView,容器用來定時(shí)地創(chuàng)建、并移動(dòng)障礙物。通過列表barsList管理當(dāng)前所有的障礙物,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) } } }
提供兩個(gè)方法start和stop,控制游戲的開始和結(jié)束:
游戲結(jié)束時(shí),要求所有障礙物停止移動(dòng)。 游戲開始后會(huì)通過Timer,定時(shí)刷新障礙物/** * 游戲結(jié)束,停止所有障礙物的移動(dòng) */ @UiThread fun stop() { _timer.cancel() _anims.forEach { it.cancel() } _anims.clear() } /** * 定時(shí)刷新障礙物: * 1. 創(chuàng)建 * 2. 添加到視圖 * 3. 移動(dòng) */ @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 ) } /** * 游戲重啟時(shí),清空障礙物 */ private fun _clearBars() { barsList.clear() removeAllViews() }
刷新障礙物
障礙物的刷新經(jīng)歷三個(gè)步驟:
創(chuàng)建:上下兩個(gè)為一組創(chuàng)建障礙物 添加:將對象添加到barsList,同時(shí)將View添加到容器 移動(dòng):通過屬性動(dòng)畫從右側(cè)移動(dòng)到左側(cè),并在移出屏幕后刪除創(chuàng)建障礙物時(shí)會(huì)為其設(shè)置隨機(jī)高度,隨機(jī)不能太過,要以前一個(gè)障礙物為基礎(chǔ)進(jìn)行適當(dāng)調(diào)整,保證隨機(jī)的同時(shí)兼具連貫性
/** * 創(chuàng)建障礙物(上下兩個(gè)為一組) */ 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() ) ) } } /** * 使用屬性動(dòng)畫移動(dòng)障礙物 */ 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() }) }}
前景(Foreground)
Boat
定會(huì)潛艇類Boat,創(chuàng)建自定義View,并提供方法移動(dòng)到指定坐標(biāo)
/** * 潛艇類 */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 /** * 移動(dòng)到指定坐標(biāo) */ fun moveTo(x: Int, y: Int) { view.smoothMoveTo(x, y) }}
BoatView
自定義View中完成以下幾個(gè)事情
通過兩個(gè)資源定時(shí)切換,實(shí)現(xiàn)探照燈閃爍的效果 通過OverScroller讓移動(dòng)過程更加順滑 通過一個(gè)Rotation Animation,讓潛艇在移動(dòng)時(shí)可以調(diào)轉(zhuǎn)角度,更加靈動(dòng)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() } } /** * 移動(dòng)更加順換 */ 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成員持有潛艇對象,并對其進(jìn)行控制 實(shí)現(xiàn)CameraHelper.FaceDetectListener根據(jù)人臉識別的回調(diào),移動(dòng)潛艇到指定位置 游戲開始時(shí),創(chuàng)建潛艇并做開場動(dòng)畫/** * 前景容器類 */class ForegroundView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs), CameraHelper.FaceDetectListener { private var _isStop: Boolean = false internal var boat: Boat? = null /** * 游戲停止,潛艇不再移動(dòng) */ @MainThread fun stop() { _isStop = true } /** * 接受人臉識別的回調(diào),移動(dòng)位置 */ override fun onFaceDetect(faces: Array<Face>, facesRect: ArrayList<RectF>) { if (_isStop) return if (facesRect.isNotEmpty()) { boat?.run { val face = facesRect.first() val x = (face.left - _widthOffset).toInt() val y = (face.top + _heightOffset).toInt() moveTo(x, y) } _face = facesRect.first() } }}
開場動(dòng)畫
游戲開始時(shí),將潛艇通過動(dòng)畫移動(dòng)到起始位置,即y軸的二分之一處
/** * 游戲開始時(shí)通過動(dòng)畫進(jìn)入 */ @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() } } } }
相機(jī)(Camera)
相機(jī)部分主要有TextureView和CameraHelper組成。TextureView提供給Camera承載preview;工具類CameraHelper主要完成以下功能:
開啟相機(jī):通過CameraManger代開攝像頭 攝像頭切換:切換前后置攝像頭, 預(yù)覽:獲取Camera提供的可預(yù)覽尺寸,并適配TextureView顯示 人臉識別:檢測人臉位置,進(jìn)行TestureView上的坐標(biāo)變換相機(jī)硬件提供的可預(yù)覽尺寸與屏幕實(shí)際尺寸(即TextureView尺寸)可能不一致,所以需要在相機(jī)初始化時(shí),選取最合適的PreviewSize,避免TextureView上發(fā)生畫面拉伸等異常
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() //人臉檢測坐標(biāo)轉(zhuǎn)換矩陣 private var mFacesRect = ArrayList<RectF>() //保存人臉坐標(biāo)信息 private var mFaceDetectListener: FaceDetectListener? = null //人臉檢測回調(diào) 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('沒有可用相機(jī)') return } //獲取攝像頭方向 mCameraSensorOrientation = mCameraCharacteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!! //獲取StreamConfigurationMap,它是管理攝像頭支持的所有輸出格式和尺寸 val configurationMap = mCameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!! val previewSize = configurationMap.getOutputSizes(SurfaceTexture::class.java) //預(yù)覽尺寸 // 當(dāng)屏幕為垂直的時(shí)候需要把寬高值進(jìn)行調(diào)換,保證寬大于高 mPreviewSize = getBestSize( mTextureView.height, mTextureView.width, previewSize.toList() ) //根據(jù)preview的size設(shè)置TextureView mTextureView.surfaceTexture.setDefaultBufferSize(mPreviewSize.width, mPreviewSize.height) mTextureView.setAspectRatio(mPreviewSize.height, mPreviewSize.width) }
選取preview尺寸的原則與TextureView的長寬比盡量一致,且面積盡量接近。
private fun getBestSize( targetWidth: Int, targetHeight: Int, sizeList: List<Size> ): Size { val bigEnough = ArrayList<Size>() //比指定寬高大的Size列表 val notBigEnough = ArrayList<Size>() //比指定寬高小的Size列表 for (size in sizeList) { //寬高比 == 目標(biāo)值寬高比 if (size.width == size.height * targetWidth / targetHeight ) { if (size.width >= targetWidth && size.height >= targetHeight) bigEnough.add(size) else notBigEnough.add(size) } } //選擇bigEnough中最小的值 或 notBigEnough中最大的值 return when { bigEnough.size > 0 -> Collections.min(bigEnough, CompareSizesByArea()) notBigEnough.size > 0 -> Collections.max(notBigEnough, CompareSizesByArea()) else -> sizeList[0] }initFaceDetect() }
initFaceDetect()用來進(jìn)行人臉的Matrix初始化,后文介紹
人臉識別
為相機(jī)預(yù)覽,創(chuàng)建一個(gè)CameraCaptureSession對象,會(huì)話通過CameraCaptureSession.CaptureCallback返回TotalCaptureResult,通過參數(shù)可以讓其中包括人臉識別的相關(guān)信息
/** * 創(chuàng)建預(yù)覽會(huì)話 */ private fun createCaptureSession(cameraDevice: CameraDevice) { // 為相機(jī)預(yù)覽,創(chuàng)建一個(gè)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對人臉信息進(jìn)行矩陣變化,確定人臉坐標(biāo)以使其準(zhǔn)確應(yīng)用到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線程將包含人臉坐標(biāo)的Rect通過回調(diào)傳出:
mActivity.runOnUiThread { mFaceDetectListener?.onFaceDetect(faces, mFacesRect) }
FaceDetectMatrix
mFaceDetectMatrix是在獲取PreviewSize之后創(chuàng)建的
/** * 初始化人臉檢測相關(guān)信息 */ 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('相機(jī)硬件不支持人臉檢測') return } val activeArraySizeRect = mCameraCharacteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)!! //獲取成像區(qū)域 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() ) }
控制類(GameController)
三大視圖層組裝完畢,最后需要一個(gè)總控類,對游戲進(jìn)行邏輯控制
GameController
主要完成以下工作:
控制游戲的開啟/停止 計(jì)算游戲的當(dāng)前得分 檢測潛艇的碰撞 對外(Activity或者Fragment等)提供游戲狀態(tài)監(jiān)聽的接口游戲開始時(shí)進(jìn)行相機(jī)的初始化,創(chuàng)建GameHelper類并建立setFaceDetectListener回調(diào)到ForegroundView
class GameController( private val activity: AppCompatActivity, private val textureView: AutoFitTextureView, private val bg: BackgroundView, private val fg: ForegroundView) { private var camera2HelperFace: CameraHelper? = null /** * 相機(jī)初始化 */ 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) } } }) } } }
游戲狀態(tài)
定義GameState,對外提供狀態(tài)的監(jiān)聽。目前支持三種狀態(tài)
Start:游戲開始 Over:游戲結(jié)束 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的時(shí)候,更新狀態(tài)
/** * 游戲狀態(tài) */ 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) }
計(jì)算得分
游戲啟動(dòng)時(shí)通過startScoring開始計(jì)算得分并通過GameState上報(bào)。目前的規(guī)則設(shè)置很簡單,存活時(shí)間即游戲得分
/** * 開始計(jì)分 */ 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根據(jù)潛艇和障礙物當(dāng)前位置,計(jì)算是否發(fā)生了碰撞,發(fā)生碰撞則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 }
Activity
Activity的工作簡單:
權(quán)限申請:動(dòng)態(tài)申請Camera權(quán)限 監(jiān)聽游戲狀態(tài):創(chuàng)建GameController,并監(jiān)聽GameState狀態(tài)private fun startGame() { PermissionUtils.checkPermission(this, Runnable { gameController.start() gameController.gameState.observe(this, Observer { when (it) { is GameState.Start -> score.text = 'DANGERnAHEAD' is GameState.Score -> score.text = '${it.score / 10f} m' is GameState.Over -> AlertDialog.Builder(this) .setMessage('游戲結(jié)束!成功推進(jìn) ${it.score / 10f} 米! ') .setNegativeButton('結(jié)束游戲') { _: DialogInterface, _: Int -> finish() }.setCancelable(false) .setPositiveButton('再來一把') { _: DialogInterface, _: Int -> gameController.start() }.show() } }) }) }
最后
項(xiàng)目結(jié)構(gòu)很清晰,用到的大都是常規(guī)技術(shù),即使是新入坑Android的同學(xué)看起來也不費(fèi)力。在現(xiàn)有基礎(chǔ)上還可以通過添加BGM、增加障礙物種類等,進(jìn)一步提高游戲性。喜歡的話留個(gè)star鼓勵(lì)一下作者吧 ^^https://github.com/vitaviva/ugame
到此這篇關(guān)于Android 實(shí)現(xiàn)抖音小游戲潛艇大挑戰(zhàn)的思路詳解的文章就介紹到這了,更多相關(guān)android 抖音游戲潛艇大挑戰(zhàn)內(nèi)容請搜索好吧啦網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持好吧啦網(wǎng)!
相關(guān)文章: