【Android】手撸抖音小游戏潜艇大挑战
《潜水艇大挑战》是抖音上的一款小游戏,以面部识别来驱动潜艇通过障碍物,最近特别火爆,相信很多人都玩过。
一时兴起自己用Android自定义View也撸了一个,发现只要有好的创意,不用高深的技术照样可以开发出好玩的应用。开发过程现拿出来与大家分享一下。
项目地址:
https://github.com/vitaviva/ugame
基本思路
整个游戏视图可以分成三层:
- 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"><!-- 相机 --><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:定义并控制障碍物和潜艇
- 属性动画:控制障碍物和潜艇的移动及各种动效
少啰嗦,先看东西!下面介绍各部分代码的实现。
后景(Background)
Bar
首先定义障碍物基类Bar
,主要负责是将bitmap资源绘制到指定区域。由于障碍物从屏幕右侧定时刷新时的高度随机,所以其绘制区域的x、y、w、h
需要动态设置
/*** 障碍物基类*/
sealed class Bar(context: Context) {protected open val bmp = context.getDrawable(R.mipmap.bar)!!.toBitmap()protected abstract val srcRect: Rectprivate lateinit var dstRect: Rectprivate val paint = Paint()var h = 0Fset(value) {field = valuedstRect = Rect(0, 0, w.toInt(), h.toInt())}var w = 0Fset(value) {field = valuedstRect = Rect(0, 0, w.toInt(), h.toInt())}var x = 0Fset(value) {view.x = valuefield = value}val yget() = view.yinternal 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))}
}
障碍物分为上方和下方两种,由于使用了同一张资源,所以绘制时要区别对待,因此定义了两个子类: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: Rectget() = _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: Rectget() = _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.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
,定时刷新障碍物
/*** 游戏结束,停止所有障碍物的移动*/@UiThreadfun stop() {_timer.cancel()_anims.forEach { it.cancel() }_anims.clear()}/*** 定时刷新障碍物:* 1. 创建* 2. 添加到视图* 3. 移动*/@UiThreadfun 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 -> -_stepit.up.h <= _step -> _step_random.nextBoolean() -> _stepelse -> -_step}it.up.h + step} ?: _barHeightw = _barWidth}val down = DnBar(context, this).apply {h = height - up.h - _gapw = _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 Floatif (bar.x + bar.w <= 0) {post { removeView(bar.view) }}}}duration = BAR_MOVE_DURATION_MILLISinterpolator = LinearInterpolator()start()})}}
前景(Foreground)
Boat
定会潜艇类Boat
,创建自定义View,并提供方法移动到指定坐标
/*** 潜艇类*/
class Boat(context: Context) {internal val view by lazy { BoatView(context) }val hget() = view.height.toFloat()val wget() = view.width.toFloat()val xget() = view.xval yget() = 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? = nullprivate var _cnt = 0set(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 = 100start()}postInvalidateOnAnimation()}
}
ForegroundView
- 通过
boat
成员持有潜艇对象,并对其进行控制 - 实现
CameraHelper.FaceDetectListener
根据人脸识别的回调,移动潜艇到指定位置 - 游戏开始时,创建潜艇并做开场动画
/*** 前景容器类*/
class ForegroundView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs),CameraHelper.FaceDetectListener {private var _isStop: Boolean = falseinternal var boat: Boat? = null/*** 游戏停止,潜艇不再移动*/@MainThreadfun stop() {_isStop = true}/*** 接受人脸识别的回调,移动位置*/override fun onFaceDetect(faces: Array<Face>, facesRect: ArrayList<RectF>) {if (_isStop) returnif (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轴的二分之一处
/*** 游戏开始时通过动画进入*/@MainThreadfun start() {_isStop = falseif (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()}}}}
相机(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: CameraManagerprivate var mCameraDevice: CameraDevice? = nullprivate var mCameraCaptureSession: CameraCaptureSession? = nullprivate 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 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设置TextureViewmTextureView.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) {//宽高比 == 目标值宽高比if (size.width == size.height * targetWidth / targetHeight) {if (size.width >= targetWidth && size.height >= targetHeight)bigEnough.add(size)elsenotBigEnough.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()
用来进行人脸的Matrix初始化,后文介绍
人脸识别
为相机预览,创建一个CameraCaptureSession
对象,会话通过CameraCaptureSession.CaptureCallback
返回TotalCaptureResult
,通过参数可以让其中包括人脸识别的相关信息
/*** 创建预览会话*/private fun createCaptureSession(cameraDevice: CameraDevice) {// 为相机预览,创建一个CameraCaptureSession对象cameraDevice.createCaptureSession(arrayListOf(surface),object : CameraCaptureSession.StateCallback() {override fun onConfigured(session: CameraCaptureSession) {mCameraCaptureSession = sessionsession.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.boundsval left = bounds.leftval top = bounds.topval right = bounds.rightval bottom = bounds.bottomval 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_FULLfaceDetectModes!!.contains(CaptureRequest.STATISTICS_FACE_DETECT_MODE_SIMPLE) -> CaptureRequest.STATISTICS_FACE_DETECT_MODE_FULLelse -> 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_FRONTmFaceDetectMatrix.setRotate(mCameraSensorOrientation.toFloat())mFaceDetectMatrix.postScale(if (mirror) -scaledHeight else scaledHeight, scaledWidth)// 注意交换width和height的位置!mFaceDetectMatrix.postTranslate(mPreviewSize.height.toFloat(),mPreviewSize.width.toFloat())}
控制类(GameController)
三大视图层组装完毕,最后需要一个总控类,对游戏进行逻辑控制
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.Starthandler.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}
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
【Android】手撸抖音小游戏潜艇大挑战相关推荐
- JAVA抖音潜艇挑战_Android 实现抖音小游戏潜艇大挑战的思路详解
<潜水艇大挑战>是抖音上的一款小游戏,以面部识别来驱动潜艇通过障碍物,最近特别火爆,相信很多人都玩过. 一时兴起自己用Android自定义View也撸了一个,发现只要有好的创意,不用高深的 ...
- Unity 之 发布字节抖音小游戏
Unity 之 发布字节抖音小游戏 一,准备工作 1.1 注册字节开发者后台 1.2 Unity版本说明 1.3 检查AppID是否有效 二,开始集成 2.1 创建项目 2.2 接入SDK 三,发布游 ...
- Unity发布抖音小游戏:构建与发布
上篇介绍了如何将字节小游戏SDK接入Unity项目中, 本篇将介绍如何使用Unity字节小游戏构建发布工具,调试和发布字节小游戏.读者可参考教程:Docs 点击菜单ByteGame-StarkSDKT ...
- 抖音小游戏推广爆火,背后有什么特点?想进圈应注意哪些方面?
今天给大家推荐一个最简单的.普通人都能操作的副业.这个副业小项目就是抖音小游戏推广. 什么是抖音游戏推广呢?(更多精彩干货请关注共众号:萤火宠) 就是这个玩意,你们刷视频的时候有时候会刷到这种玩游戏 ...
- Created with Cocos丨抖音小游戏杀疯了!这几个脑洞清奇的作品越玩越上瘾
据抖音官方公开的数据显示,目前抖音日活用户已超过6亿.在如此大的流量.以及抖音对小游戏业务的大力扶持面前,游戏厂商和开发者恐怕已经很难忽视抖音小游戏平台的发展空间. 而近段时间的抖音小游戏平台上,以& ...
- 【抖音小游戏】 Unity制作抖音小游戏方案 最新完整详细教程来袭【持续更新】
前言 [抖音小游戏] Unity制作抖音小游戏方案 最新完整详细教程来袭[持续更新] 一.相关准备工作 1.1 用到的相关网址 1.2 注册字节开发者后台账号 二.相关集成工作 2.1 下载需要的集成 ...
- Laya教程-对接抖音小游戏sdk(10分钟掌握)
抖音小游戏开发 视频演讲稿 LAYA对接抖音小游戏(10分钟掌握) 演讲稿: 本节内容讲的是:Laya对接抖音小游戏平台 功能点包括: banner广告,激励视频,插屏广告,渠道游戏列表展示,视频录制 ...
- 抖音开发者工具配置抖音小游戏为横屏显示的方法
本篇文章主要讲解,使用抖音开发者工具配置抖音小游戏为横屏显示的方法 作者:任聪聪 日期:2023年2月3日 问题现象 横屏的游戏发布到抖音开发者工具中发现是竖屏显示 实际原因 game.json的配置 ...
- 抖音小游戏背后:醉翁之意不在酒
公元前353年,赵国被魏围困于国都邯郸,赵王遂向齐国求救.齐兵依孙膑之计直取魏国国都迫使魏兵回援,遂解赵国之困,这就是著名的围魏救赵. 回到现在,中国互联网行业也正如千年之前的战国一般,"战 ...
- 万万没想到!爆款抖音小游戏的秘诀竟然是......
先睹为快: 1.专业做抖音小游戏运营和发行的飞鹿游戏,发行抖音小游戏累积1000万用户,小游戏视频播放量达到了1亿次,数据背后总结了哪些经验?踩了哪些坑? 2.小游戏上线要考虑人和(用户痒点爽点找到了 ...
最新文章
- mysql 集群 增加服务器_MYSQL集群服务配置
- 搜索:广搜 词语阶梯
- 0基础JavaScript入门教程(一)认识代码
- 《Python基础教程第二版》第五章-条件、循环和其他语句(一)
- ibatis提示Unable to load embedded resource from assembly Entity.Ce_SQL.xml,Entity.
- pyspark groupBy代码示例
- AUTOSAR从入门到精通100讲(七十九)-AUTOSAR基础篇之DTC
- html5的方框属性,HTML连载37-边框属性(下)、边框练习
- chrome最强大的浏览器插件推荐,只要你会用其他的插件你可以删除了
- 14道Python基础练习题(附答案)
- CSS权威指南(1)
- 机顶盒固件简单做刷机包方法
- word多级标题下一级和上一级没有关联上
- 华硕笔记本触控板设置 Smart Gesture
- 《STL源码剖析》--memery
- Gradle‘s dependency cache may be corrupt (this sometimes occurs after a network connection timeout)
- 手写redis@Cacheable注解 支持过期时间设置
- QGC地面站配置PX4Flow光流传感器
- TM1637数码管实验总结
- [原创]gsoap的基本使用方法『C++web服务工具包』
热门文章
- ARM9开发板初体验----使用Uboot通过USB下载线烧写bin文件
- SSH移植到arm开发板
- (九)ThunderbirdMail配置QQ邮件服务
- Latex VS Code 编辑中文Latex乱码——详细解决方案操作流程
- 大工计算机英语考试,大工15春《专业英语(计算机英语)》在线测试123
- css3直线运动_纯CSS3炫酷元素边框线条动画特效
- oracle mysql 同义词_Oracle中的同义词SYNONYM
- Mina MEID/GSM Activator 1.0 三网信号激活,支持iOS12.0~14.8.1
- win10 kms activator
- html页面计算圆的周长和面积,计算圆的周长和面积之间的差-JavaScript