作者:fundroid_方卓
链接:https://blog.csdn.net/vitaviva/article/details/105613652
《潜水艇大挑战》是抖音上的一款小游戏,以面部识别来驱动潜艇通过障碍物,最近特别火爆,相信很多人都玩过。
一时兴起自己用Android自定义View也撸了一个,发现只要有好的创意,不用高深的技术照样可以开发出好玩的应用。开发过程现拿出来与大家分享一下。
需要学习的内容NDK模块开发
音视频的开发,往往是比较难的,而这个比较难的技术就是NDK里面的技术。音视频/高清大图片/人工智能/直播/抖音等等这年与用户最紧密,与我们生活最相关的技术一直都在寻找最终的技术落地平台,以前是windows系统,而现在则是移动系统了,移动系统中又是以Android占比绝大部分为前提,所以AndroidNDK技术已经是我们必备技能了。要学习好NDK,其中的关于C/C ,jni,Linux基础都是需要学习的,除此之外,音视频的编解码技术,流媒体协议,ffmpeg这些都是音视频开发必备技能,而且OpenCV/OpenGl/这些又是图像处理必备知识,这些都是需要学习的。
需要下面资料视频的可以私信我【进阶】我免费分享给你,希望对大家有帮助。
学习视频
基本思路整个游戏视图可以分成三层:
- camera(相机):处理相机的preview以及人脸识别
- background(后景):处理障碍物相关逻辑
- foreground(前景):处理潜艇相关
代码也是按上面三个层面组织的,游戏界面的布局可以简单理解为三层视图的叠加,然后在各层视图中完成相关工作
<FrameLayoutxmlns: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需要动态设置
/**
*屏幕下方障碍物
*/
classDnBar(context:Context,container:ViewGroup):Bar(context){
overridevalbmp=super.bmp.let{
Bitmap.createBitmap(
it,0,0,it.width,it.height,
Matrix().apply{postRotate(-180F)},true
)
}
privateval_srcRectbylazy(LazyThreadSafetyMode.NONE){
Rect(0,0,bmp.width,(bmp.height*(h/container.height)).toInt())
}
overridevalsrcRect:Rect
get()=_srcRect
}
障碍物分为上方和下方两种,由于使用了同一张资源,所以绘制时要区别对待,因此定义了两个子类:UpBar和DnBar
下方障碍物的资源旋转180度后绘制
/**
*屏幕下方障碍物
*/
classDnBar(context:Context,container:ViewGroup):Bar(context){
overridevalbmp=super.bmp.let{
Bitmap.createBitmap(
it,0,0,it.width,it.height,
Matrix().apply{postRotate(-180F)},true
)
}
privateval_srcRectbylazy(LazyThreadSafetyMode.NONE){
Rect(0,0,bmp.width,(bmp.height*(h/container.height)).toInt())
}
overridevalsrcRect:Rect
get()=_srcRect
}
BackgroundView
接下来创建后景的容器BackgroundView,容器用来定时地创建、并移动障碍物。
通过列表barsList管理当前所有的障碍物,onLayout中,将障碍物分别布局到屏幕上方和下方
/**
*后景容器类
*/
classBackgroundView(context:Context,attrs:AttributeSet?):FrameLayout(context,attrs){
internalvalbarsList=mutableListOf<Bars>()
overridefunonLayout(changed:Boolean,left:Int,top:Int,right:Int,bottom:Int){
barsList.flatMap{listOf(it.up,it.down)}.forEach{
valw=it.view.measuredWidth
valh=it.view.measuredHeight
when(it){
isUpBar->it.view.layout(0,0,w,h)
else->it.view.layout(0,height-h,w,height)
}
}
}
提供两个方法start和stop,控制游戏的开始和结束:
- 游戏结束时,要求所有障碍物停止移动。
- 游戏开始后会通过Timer,定时刷新障碍物
/**
*游戏结束,停止所有障碍物的移动
*/
@UiThread
funstop(){
_timer.cancel()
_anims.forEach{it.cancel()}
_anims.clear()
}
/**
*定时刷新障碍物:
*1.创建
*2.添加到视图
*3.移动
*/
@UiThread
funstart(){
_clearBars()
Timer().also{_timer=it}.schedule(object:TimerTask(){
overridefunrun(){
post{
_createBars(context,barsList.lastOrNull()).let{
_addBars(it)
_moveBars(it)
}
}
}
},FIRST_APPEAR_DELAY_MILLIS,BAR_APPEAR_INTERVAL_MILLIS
)
}
/**
*游戏重启时,清空障碍物
*/
privatefun_clearBars(){
barsList.clear()
removeAllViews()
}
刷新障碍物
障碍物的刷新经历三个步骤:
- 创建:上下两个为一组创建障碍物
- 添加:将对象添加到barsList,同时将View添加到容器
- 移动:通过属性动画从右侧移动到左侧,并在移出屏幕后删除
创建障碍物时会为其设置随机高度,随机不能太过,要以前一个障碍物为基础进行适当调整,保证随机的同时兼具连贯性
/**
*创建障碍物(上下两个为一组)
*/
privatefun_createBars(context:Context,pre:Bars?)=run{
valup=UpBar(context,this).apply{
h=pre?.let{
valstep=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
}
valdown=DnBar(context,this).apply{
h=height-up.h-_gap
w=_barWidth
}
Bars(up,down)
}
/**
*添加到屏幕
*/
privatefun_addBars(bars:Bars){
barsList.add(bars)
bars.asArray().forEach{
addView(
it.view,
ViewGroup.LayoutParams(
it.w.toInt(),
it.h.toInt()
)
)
}
}
/**
*使用属性动画移动障碍物
*/
privatefun_moveBars(bars:Bars){
_anims.add(
ValueAnimator.ofFloat(width.toFloat(),-_barWidth)
.apply{
addUpdateListener{
bars.asArray().forEach{bar->
bar.x=it.animatedValueasFloat
if(bar.x bar.w<=0){
post{removeView(bar.view)}
}
}
}
duration=BAR_MOVE_DURATION_MILLIS
interpolator=LinearInterpolator()
start()
})
}
}
3、前景(Foreground)
Boat
定义潜艇类Boat,创建自定义View,并提供方法移动到指定坐标
/**
*潜艇类
*/
classBoat(context:Context){
internalvalviewbylazy{BoatView(context)}
valh
get()=view.height.toFloat()
valw
get()=view.width.toFloat()
valx
get()=view.x
valy
get()=view.y
/**
*移动到指定坐标
*/
funmoveTo(x:Int,y:Int){
view.smoothMoveTo(x,y)
}
}
BoatView
自定义View中完成以下几个事情
- 通过两个资源定时切换,实现探照灯闪烁的效果
- 通过OverScroller让移动过程更加顺滑
- 通过一个Rotation Animation,让潜艇在移动时可以调转角度,更加灵动
internalclassBoatView(context:Context?):AppCompatImageView(context){
privateval_scrollerbylazy{OverScroller(context)}
privateval_res=arrayOf(
R.mipmap.boat_000,
R.mipmap.boat_002
)
privatevar_rotationAnimator:ObjectAnimator?=null
privatevar_cnt=0
set(value){
field=if(value>1)0elsevalue
}
init{
scaleType=ScaleType.FIT_CENTER
_startFlashing()
}
privatefun_startFlashing(){
postDelayed({
setImageResource(_res[_cnt ])
_startFlashing()
},500)
}
overridefuncomputeScroll(){
super.computeScroll()
if(_scroller.computeScrollOffset()){
x=_scroller.currX.toFloat()
y=_scroller.currY.toFloat()
//Keepondrawinguntiltheanimationhasfinished.
postInvalidateOnAnimation()
}
}
/**
*移动更加顺换
*/
internalfunsmoothMoveTo(x:Int,y:Int){
if(!_scroller.isFinished)_scroller.abortAnimation()
_rotationAnimator?.let{if(it.isRunning)it.cancel()}
valcurX=this.x.toInt()
valcurY=this.y.toInt()
valdx=(x-curX)
valdy=(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
funstart(){
_isStop=false
if(boat==null){
boat=Boat(context).also{
post{
addView(it.view,_width,_width)
AnimatorSet().apply{
play(
ObjectAnimator.ofFloat(
it.view,
"y",
0F,
this@ForegroundView.height/2f
)
).with(
ObjectAnimator.ofFloat(it.view,"rotation",0F,360F)
)
doOnEnd{_->it.view.rotation=0F}
duration=1000
}.start()
}
}
}
}
开场动画
游戏开始时,将潜艇通过动画移动到起始位置,即y轴的二分之一处
/**
*游戏开始时通过动画进入
*/
@MainThread
funstart(){
_isStop=false
if(boat==null){
boat=Boat(context).also{
post{
addView(it.view,_width,_width)
AnimatorSet().apply{
play(
ObjectAnimator.ofFloat(
it.view,
"y",
0F,
this@ForegroundView.height/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上发生画面拉伸等异常
classCameraHelper(valmActivity:Activity,privatevalmTextureView:TextureView){
privatelateinitvarmCameraManager:CameraManager
privatevarmCameraDevice:CameraDevice?=null
privatevarmCameraCaptureSession:CameraCaptureSession?=null
privatevarcanExchangeCamera=false//是否可以切换摄像头
privatevarmFaceDetectMatrix=Matrix()//人脸检测坐标转换矩阵
privatevarmFacesRect=ArrayList<RectF>()//保存人脸坐标信息
privatevarmFaceDetectListener:FaceDetectListener?=null//人脸检测回调
privatelateinitvarmPreviewSize:Size
/**
*初始化
*/
privatefuninitCameraInfo(){
mCameraManager=mActivity.getSystemService(Context.CAMERA_SERVICE)asCameraManager
valcameraIdList=mCameraManager.cameraIdList
if(cameraIdList.isEmpty()){
mActivity.toast("没有可用相机")
return
}
//获取摄像头方向
mCameraSensorOrientation=
mCameraCharacteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!!
//获取StreamConfigurationMap,它是管理摄像头支持的所有输出格式和尺寸
valconfigurationMap=
mCameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
valpreviewSize=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,通过参数可以让其中包括人脸识别的相关信息
/**
*创建预览会话
*/
privatefuncreateCaptureSession(cameraDevice:CameraDevice){
//为相机预览,创建一个CameraCaptureSession对象
cameraDevice.createCaptureSession(
arrayListOf(surface),
object:CameraCaptureSession.StateCallback(){
overridefunonConfigured(session:CameraCaptureSession){
mCameraCaptureSession=session
session.setRepeatingRequest(
captureRequestBuilder.build(),
mCaptureCallBack,
mCameraHandler
)
}
},
mCameraHandler
)
}
privatevalmCaptureCallBack=object:CameraCaptureSession.CaptureCallback(){
overridefunonCaptureCompleted(
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之后创建的
/**
*初始化人脸检测相关信息
*/
privatefuninitFaceDetect(){
valfaceDetectModes=
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
}
valactiveArraySizeRect=
mCameraCharacteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)!!//获取成像区域
valscaledWidth=mPreviewSize.width/activeArraySizeRect.width().toFloat()
valscaledHeight=mPreviewSize.height/activeArraySizeRect.height().toFloat()
valmirror=mCameraFacing==CameraCharacteristics.LENS_FACING_FRONT
mFaceDetectMatrix.setRotate(mCameraSensorOrientation.toFloat())
mFaceDetectMatrix.postScale(if(mirror)-scaledHeightelsescaledHeight,scaledWidth)//注意交换width和height的位置!
mFaceDetectMatrix.postTranslate(
mPreviewSize.height.toFloat(),
mPreviewSize.width.toFloat()
)
}
5、控制类(GameController)
三大视图层组装完毕,最后需要一个总控类,对游戏进行逻辑控制
主要完成以下工作:
- 控制游戏的开启/停止
- 计算游戏的当前得分
- 检测潜艇的碰撞
- 对外(Activity或者Fragment等)提供游戏状态监听的接口
初始化
游戏开始时进行相机的初始化,创建GameHelper类并建立setFaceDetectListener回调到ForegroundView
classGameController(
privatevalactivity:AppCompatActivity,
privatevaltextureView:AutoFitTextureView,
privatevalbg:BackgroundView,
privatevalfg:ForegroundView
){
privatevarcamera2HelperFace:CameraHelper?=null
/**
*相机初始化
*/
privatefuninitCamera(){
cameraHelper?:run{
cameraHelper=CameraHelper(activity,textureView).apply{
setFaceDetectListener(object:CameraHelper.FaceDetectListener{
overridefunonFaceDetect(faces:Array<Face>,facesRect:ArrayList<RectF>){
if(facesRect.isNotEmpty()){
fg.onFaceDetect(faces,facesRect)
}
}
})
}
}
}
游戏状态
定义GameState,对外提供状态的监听。目前支持三种状态
- Start:游戏开始
- Over:游戏结束
- Score:游戏得分
sealedclassGameState(openvalscore:Long){
objectStart:GameState(0)
dataclassOver(overridevalscore:Long):GameState(score)
dataclassScore(overridevalscore:Long):GameState(score)
}
可以在stop、start的时候,更新状态
/**
*游戏状态
*/
privateval_state=MutableLiveData<GameState>()
internalvalgameState:LiveData<GameState>
get()=_state
/**
*游戏停止
*/
funstop(){
bg.stop()
fg.stop()
_state.value=GameState.Over(_score)
_score=0L
}
/**
*游戏开始
*/
funstart(){
initCamera()
fg.start()
bg.start()
_state.value=GameState.Start
handler.postDelayed({
startScoring()
},FIRST_APPEAR_DELAY_MILLIS)
}
计算得分
游戏启动时通过startScoring开始计算得分并通过GameState上报。
目前的规则设置很简单,存活时间即游戏得分
/**
*开始计分
*/
privatefunstartScoring(){
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
/**
*碰撞检测
*/
privatefunisCollision(
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){
returnfalse
}
returntrue
}
6、Activity
Activity的工作简单:
- 权限申请:动态申请Camera权限
- 监听游戏状态:创建GameController,并监听GameState状态
privatefunstartGame(){
PermissionUtils.checkPermission(this,Runnable{
gameController.start()
gameController.gameState.observe(this,Observer{
when(it){
isGameState.Start->
score.text="DANGER\nAHEAD"
isGameState.Score->
score.text="${it.score/10f}m"
isGameState.Over->
AlertDialog.Builder(this)
.setMessage("游戏结束!成功推进${it.score/10f}米!")
.setNegativeButton("结束游戏"){_:DialogInterface,_:Int->
finish()
}.setCancelable(false)
.setPositiveButton("再来一把"){_:DialogInterface,_:Int->
gameController.start()
}.show()
}
})
})
}
最后
项目结构很清晰,用到的大都是常规技术,即使是新入坑Android的同学看起来也不费力。在现有基础上还可以通过添加BGM、增加障碍物种类等,进一步提高游戏性。
喜欢的话留个star鼓励一下作者吧 ^^