潜艇大作战,这款抖音上很火的游戏相信很多人都玩过,以面部识别来驱动潜艇通过障碍物,今天给大家用Android原生实现一下,文末会给大家提供一个apk下载链接,供大家下载体验。代码我是用Kotlin实现的,假如你还是使用java开发,私信我给你讲解。

潜艇大战java实训报告 潜艇大战手机版_潜艇大战java实训报告

1基本思路


整个游戏视图可以分成三层:

  • camera(相机):处理相机的preview以及人脸识别
  • background(后景):处理障碍物相关逻辑
  • foreground(前景):处理潜艇相关

潜艇大战java实训报告 潜艇大战手机版_android 自定义相机界面_02

代码也是按上面三个层面组织的,游戏界面的布局可以简单理解为三层视图的叠加,然后在各层视图中完成相关工作

<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">

  
  <TextureViewandroid:layout_width="match_parent"android:layout_height="match_parent"/>

  
  <com.my.ugame.bg.BackgroundViewandroid:layout_width="match_parent"android:layout_height="match_parent"/>

  
  <com.my.ugame.fg.ForegroundViewandroid:layout_width="match_parent"android:layout_height="match_parent"/>

Framelayout>

开发中会涉及以下技术的使用:

  • 相机:使用Camera2完成相机的预览和人脸识别
  • 自定义View:定义并控制障碍物和潜艇
  • 属性动画:控制障碍物和潜艇的移动及各种动效

2

后景(Background)

Bar

首先定义障碍物基类Bar,主要负责是将bitmap资源绘制到指定区域。由于障碍物从屏幕右侧定时刷新时的高度随机,所以其绘制区域的x、y、w、h需要动态设置

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 = 0Fset(value) {
        field = value
        dstRect = Rect(0, 0, w.toInt(), h.toInt())
    }

var w = 0Fset(value) {
        field = value
        dstRect = Rect(0, 0, w.toInt(), h.toInt())
    }

var x = 0Fset(value) {
        view.x = value
        field = value
    }

val yget() = view.y

internal val view by lazy {
    BarView(context) {
        it?.apply {
            drawBitmap(
                bmp,
                srcRect,
                dstRect,
                paint
            )
        }
    }
}

障碍物分为上方和下方两种,由于使用了同一张资源,所以绘制时要区别对待,因此定义了两个子类:UpBar和DnBar

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

下方障碍物的资源旋转180度后绘制

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中,将障碍物分别布局到屏幕上方和下方

internal val barsList = mutableListOf()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.measuredWidthval h = it.view.measuredHeightwhen (it) {is UpBar -> it.view.layout(0, 0, w, h)else -> it.view.layout(0, height - h, w, height)
        }
    }

提供两个方法start和stop,控制游戏的开始和结束:

  • 游戏结束时,要求所有障碍物停止移动。
  • 游戏开始后会通过Timer,定时刷新障碍物
/**
 * 定时刷新障碍物:
 * 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. 移动:通过属性动画从右侧移动到左侧,并在移出屏幕后删除

创建障碍物时会为其设置随机高度,随机不能太过,要以前一个障碍物为基础进行适当调整,保证随机的同时兼具连贯性。

3

前景(Foreground)

Boat

定义潜艇类Boat,创建自定义View,并提供方法移动到指定坐标

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,让潜艇在移动时可以调转角度,更加灵动

ForegroundView

  • 通过boat成员持有潜艇对象,并对其进行控制
  • 实现CameraHelper.FaceDetectListener根据人脸识别的回调,移动潜艇到指定位置
  • 游戏开始时,创建潜艇并做开场动画
private var _isStop: Boolean = false

internal var boat: Boat? = null

/**
 * 游戏停止,潜艇不再移动
 */
@MainThread
fun stop() {
    _isStop = true
}

/**
 * 接受人脸识别的回调,移动位置
 */
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()
    }
}

开场动画

游戏开始时,将潜艇通过动画移动到起始位置,即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,
                            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上发生画面拉伸等异常

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()                                         //保存人脸坐标信息private var mFaceDetectListener: FaceDetectListener? = null                         //人脸检测回调private lateinit var mPreviewSize: Size/**
 * 初始化
 */private fun initCameraInfo() {
    mCameraManager = mActivity.getSystemService(Context.CAMERA_SERVICE) as CameraManagerval cameraIdList = mCameraManager.cameraIdListif (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)
}

initFaceDetect()用来进行人脸的Matrix初始化,后文介绍。

人脸识别

为相机预览,创建一个CameraCaptureSession对象,会话通过CameraCaptureSession.CaptureCallback返回TotalCaptureResult,通过参数可以让其中包括人脸识别的相关信息

// 为相机预览,创建一个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。

5

控制类(GameController)

三大视图层组装完毕,最后需要一个总控类,对游戏进行逻辑控制

主要完成以下工作:

  • 控制游戏的开启/停止
  • 计算游戏的当前得分
  • 检测潜艇的碰撞
  • 对外(Activity或者Fragment等)提供游戏状态监听的接口

初始化

游戏开始时进行相机的初始化,创建GameHelper类并建立setFaceDetectListener回调到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:游戏得分


可以在stop、start的时候,更新状态

/**
 * 游戏停止
 */
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上报。

目前的规则设置很简单,存活时间即游戏得分

检测碰撞

isCollision根据潜艇和障碍物当前位置,计算是否发生了碰撞,发生碰撞则GameOver

6游戏主界面

Activity

Activity的工作简单:

  • 权限申请:动态申请Camera权限
  • 监听游戏状态:创建GameController,并监听GameState状态
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()
        }
    })
})

总结:项目总体结构很清晰、开发语言主流,大家应该都看的懂,赶集动手试试吧。