作者:fundroid_方卓
《潛水艇大挑戰》是抖音上的一款小遊戲,以面部識別來驅動潛艇通過障礙物,最近特別火爆,相信很多人都玩過。
一時興起自己用Android自定義View也擼了一個,發現只要有好的創意,不用高深的技術照樣可以開發出好玩的應用。開發過程現拿出來與大家分享一下。
項目地址:
https://github.com/vitaviva/ugame
需要學習的內容
NDK模塊開發
音視頻的開發,往往是比較難的,而這個比較難的技術就是NDK裏面的技術。音視頻/高清大圖片/人工智能/直播/抖音等等這年與用戶最緊密,與我們生活最相關的技術一直都在尋找最終的技術落地平臺,以前是windows系統,而現在則是移動系統了,移動系統中又是以Android佔比絕大部分爲前提,所以AndroidNDK技術已經是我們必備技能了。要學習好NDK,其中的關於C/C++,jni,Linux基礎都是需要學習的,除此之外,音視頻的編解碼技術,流媒體協議,ffmpeg這些都是音視頻開發必備技能,而且OpenCV/OpenGl/這些又是圖像處理必備知識,這些都是需要學習的。
需要下面資料視頻的可以私信我【進階】我免費分享給你,或者直接點擊下面鏈接就可領取希望對大家有幫助。
如果你有其他需要的話,也可以在 GitHub 上查看,下面的資料也會陸續上傳到Github,你可以按照我的學習路線,對比一下你自己的,這個就是查漏補缺的一個過程。
學習視頻
基本思路
整個遊戲視圖可以分成三層:
- camera(相機):處理相機的preview以及人臉識別
- background(後景):處理障礙物相關邏輯
- foreground(前景):處理潛艇相關
代碼也是按上面三個層面組織的,遊戲界面的佈局可以簡單理解爲三層視圖的疊加,然後在各層視圖中完成相關工作
<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()
}
刷新障礙物
障礙物的刷新經歷三個步驟:
- 創建:上下兩個爲一組創建障礙物
- 添加:將對象添加到barsList,同時將View添加到容器
- 移動:通過屬性動畫從右側移動到左側,並在移出屏幕後刪除
創建障礙物時會爲其設置隨機高度,隨機不能太過,要以前一個障礙物爲基礎進行適當調整,保證隨機的同時兼具連貫性
/**
* 創建障礙物(上下兩個爲一組)
*/
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鼓勵一下作者吧 ^^