在上一篇教程介绍了SurfaceView的使用,也实现了一个动画循环,接下来就可以在这个循环体内绘制游戏的画面,由于我们绘制的只是游戏的一帧,所以用时不能太长,要讲究点效率,这里我们可以使用LruCache对已经绘制过的图像进行缓存,LruCache实则就是一个包装了LinkedHashMap的键值对集合,图像被缓存后可以直接从集合中取出,免去了反复重绘的时间。为了便于使用我们封闭了一个工具类:
object BmpCache {
private val mapCache = LruCache<String, Bitmap>(120)
fun get(key: String) = mapCache[key]
fun put(key: String, bmp: Bitmap) {
mapCache.put(key, bmp)
}
const val BMP_PLAYER = "player"
const val BMP_ENEMY = "enemy1"
}
绘画主要是通过SurfaceHolder的lockCanvas返回的Canvas对象来实现的。在绘画完成后再用unlockCanvasAndPost方法渲染到屏幕上。在开始之前先说明几个绘图术语:
至此我们可以正式的开始绘画了,为了便于讲解就以《空间大战》里的玩家飞船为例,第一是创建一个Bitmap对象并在上面画出飞船的模样,画完后立马缓存起来,然后在绘制动画帧的时候再从缓存中取出并画到屏幕上。
绘制并缓存的流程如下:
代码如下:
private fun buildPlayerBitmap(width: Int, height: Int): Bitmap {
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// 手绘玩家的图像
Canvas(bmp).apply {
val paint = Paint()
paint.isAntiAlias = true // 反锯齿
paint.style = Paint.Style.FILL // 实心填充
// 设置渐变着色
paint.shader = RadialGradient(
width / 2f, 0f, width.toFloat(),
intArrayOf(Color.WHITE, Color.DKGRAY), null,
Shader.TileMode.CLAMP
)
// 定义多边形的路径
val path = Path()
path.moveTo(width / 2f, 0f)
path.lineTo(width.toFloat(), height - (height / 3f))
path.lineTo(width / 2f, height.toFloat())
path.lineTo(0f, height - (height / 3f))
path.close()
this.drawPath(path, paint) // 绘制多边形,样式为实心、渐变
// 再次设置笔刷样式为空心,边线为1像素,撤销之前的着色器
paint.style = Paint.Style.STROKE
paint.strokeWidth = 1f
paint.shader = null
paint.color = Color.WHITE
paint.strokeJoin = Paint.Join.ROUND
this.drawPath(path, paint) // 绘制多边形的边框
this.drawLine(width / 2f, 0f, width / 2f, height.toFloat(), paint)
}
BmpCache.put("player", bmp)
return bmp
}
为了适配不同分辨率,我们取SurfaceView尺寸中最窄的一边的二十分之一为依据,作为玩家Bitmap对象的尺寸。接着用Path对象勾勒出飞船的多边形轮廓,再用Paint对象填充多边形的内部,由于已经给Paint对象设置了一个渐变着色器,所以填充后的多边形内部就呈现出渐变的效果。
Paint对象有一个特点;就是当图形被画到Bitmap上后就和Paint无关了,此时对Paint再次设置颜色等属性时不会影响到已经画到Bitmap上的图形,这概念相当于我们画完一幅图后不用换笔刷,只需要洗一下笔刷重新蘸着颜料就能画画一样。所以再次对Paint对象进行设置,把填充模式改为空心,白色线条并撤销着色器,然后用之前的Path对象勾出一个白色的边框,并在中心画一线白线,最后的效果如下图:

上述代码只是把玩家的飞船画到了Bitmap对象并且缓存了起来,接着就是把Bitmap对象画到屏幕指定的位置上的流程:
private fun drawPlayer(
canvas: Canvas,
surfaceWidth: Int,
surfaceHeight: Int,
degrees: Float
) {
val bmp = if (BmpCache.get("player") == null) {
val size = if (surfaceWidth > surfaceHeight) surfaceHeight / 20 else surfaceWidth / 20
buildPlayerBitmap(size, size)
} else {
BmpCache.get("player")
}
// 将玩家的Bitmap绘制到Surface的中心,坐标需要根据Bitmap的宽度作出偏移
val centerX = (surfaceWidth - bmp.width) / 2f
val centerY = (surfaceHeight - bmp.height) / 2f
// withRotation用于对图形进行旋转,pivotX和pivotY指定的是旋转的轴坐标
canvas.withRotation(degrees, surfaceWidth/2f, surfaceHeight/2f) {
canvas.drawBitmap(bmp, centerX, centerY, null)
// 为了便于观察,在Bitmap外面套了一层矩形
paint.style = Paint.Style.STROKE
paint.color = Color.parseColor("#99FFFF00")
canvas.drawRect(centerX, centerY, centerX + bmp.width, centerY + bmp.height, paint)
}
}
上述代码中用withRotation和drawBitmap方法结合,即指定了Bitmap绘制在屏幕上的坐标,又指定了旋转的角度。这里面drawBitmap是从Bitmap的左上角开始显示的,所以对显示Bitmap的坐标要进行转换。而withRotation的轴坐标则是针对屏幕来定位的。
再结合上一篇Android游戏教程:SurfaceView - 游戏开始的地方最后的动画框架,我们实现了一个在屏幕正中不断旋转的玩家飞船的效果。

class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
private lateinit var surfaceView: SurfaceView
private var isLooping = false // 控制动画循环的运行和结束
private val paint = Paint()
private var degrees = 0f
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initSurfaceView()
}
override fun onDestroy() {
super.onDestroy()
cancel()
}
/**
* 初始化SurfaceView
*/
private fun initSurfaceView() {
surfaceView = findViewById(R.id.surfaceView)
surfaceView.holder.setKeepScreenOn(true) // 屏幕常亮
surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
isLooping = true
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int
) {
launch(Dispatchers.Default) {
while (isLooping) {
val canvas = holder.lockCanvas()
// 记录帧开始的时间
val startMillis = System.currentTimeMillis()
// 在每帧开始的时候都要黑色矩形作为背景覆盖掉上次的画面
paint.color = Color.BLACK
canvas.drawRect(0f, 0f, width - 1f, height - 1f, paint)
drawFrame(canvas, width, height)
// 记录帧结束的时间
val endMillis = System.currentTimeMillis()
// 使画面保持在每秒60帧以内
val frameDelay = 1000 / 60 - (startMillis - endMillis)
if (frameDelay > 0) delay(frameDelay)
// 在屏幕上显示FPS
drawFPS(canvas, startMillis, System.currentTimeMillis())
holder.unlockCanvasAndPost(canvas)
}
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
isLooping = false
}
})
}
/**
* 显示FPS
*/
private fun drawFPS(canvas: Canvas, startMillis: Long, endMillis: Long) {
paint.let {
it.color = Color.WHITE
it.style = Paint.Style.FILL
it.textSize = dp2px(14f)
}
val fps = 1000 / (endMillis - startMillis)
canvas.drawText("FPS:$fps", 10f, dp2px(20f), paint)
}
private fun dp2px(dp: Float): Float {
return dp * resources.displayMetrics.density + 0.5f
}
private fun drawFrame(canvas: Canvas, surfaceWidth: Int, surfaceHeight: Int) {
paint.color = Color.BLUE
canvas.drawLine(0f, surfaceHeight / 2f, surfaceWidth - 1f, surfaceHeight / 2f, paint)
canvas.drawLine(surfaceWidth / 2f, 0f, surfaceWidth / 2f, surfaceHeight - 1f, paint)
drawPlayer(canvas, surfaceWidth, surfaceHeight, degrees++)
if (degrees > 360f) degrees = 0f
}
private fun drawPlayer(
canvas: Canvas,
surfaceWidth: Int,
surfaceHeight: Int,
degrees: Float
) {
val bmp = if (BmpCache.get("player") == null) {
val size = if (surfaceWidth > surfaceHeight) surfaceHeight / 20 else surfaceWidth / 20
buildPlayerBitmap(size, size)
} else {
BmpCache.get("player")
}
// 将玩家的Bitmap绘制到Surface的中心,坐标需要根据Bitmap的宽度作出偏移
val centerX = (surfaceWidth - bmp.width) / 2f
val centerY = (surfaceHeight - bmp.height) / 2f
// withRotation用于对图形进行旋转,pivotX和pivotY指定的是旋转的轴坐标
canvas.withRotation(degrees, surfaceWidth/2f, surfaceHeight/2f) {
canvas.drawBitmap(bmp, centerX, centerY, null)
// 为了便于观察,在Bitmap外面套了一层矩形
paint.style = Paint.Style.STROKE
paint.color = Color.parseColor("#99FFFF00")
canvas.drawRect(centerX, centerY, centerX + bmp.width, centerY + bmp.height, paint)
}
}
private fun buildPlayerBitmap(width: Int, height: Int): Bitmap {
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// 手绘玩家的图像
Canvas(bmp).apply {
val paint = Paint()
paint.isAntiAlias = true // 反锯齿
paint.style = Paint.Style.FILL // 实心填充
// 设置渐变着色
paint.shader = RadialGradient(
width / 2f, 0f, width.toFloat(),
intArrayOf(Color.WHITE, Color.DKGRAY), null,
Shader.TileMode.CLAMP
)
// 定义多边形的路径
val path = Path()
path.moveTo(width / 2f, 0f)
path.lineTo(width.toFloat(), height - (height / 3f))
path.lineTo(width / 2f, height.toFloat())
path.lineTo(0f, height - (height / 3f))
path.close()
this.drawPath(path, paint) // 绘制多边形,样式为实心、渐变
// 再次设置笔刷样式为空心,边线为1像素,撤销之前的着色器
paint.style = Paint.Style.STROKE
paint.strokeWidth = 1f
paint.shader = null
paint.color = Color.WHITE
paint.strokeJoin = Paint.Join.ROUND
this.drawPath(path, paint) // 绘制多边形的边框
this.drawLine(width / 2f, 0f, width / 2f, height.toFloat(), paint)
}
BmpCache.put("player", bmp)
return bmp
}
}
如果对我的文章感兴趣的话可以搜索和关注微公众号或Q群聊:口袋里的安卓
¥11.90
【百亿补贴】盐津铺子零食大礼包休闲食品小吃晚上解饿小零食大全
¥7.60
玻璃爽油膜去除剂前挡风车窗净玻璃水清洁去油膜清洗汽车用品大全
¥25.80
奶油胶咕卡套装大全套手账贴纸女孩diy玩具咕咔盘儿童贴画手工故孤酷古卡工具材料素材火漆印章装饰生日礼物
¥12.80
儿童趣味百科全书漫画版硬壳精装 十万个为什么幼儿版3-6-7-8岁亲子阅读绘本幼儿园宝宝科普启蒙早教读物小学生课外故事书籍大全
¥21.86
奶油胶咕卡套装贴纸古卡酷卡儿童玩具女孩diy咕咔盘贴画豪华版姑卡箱顾库估孤卡做手帐收纳盒大全套材料包
¥36.80
礼炮礼花结婚专用礼筒炮礼花筒婚礼用品大全婚庆喷花筒泡筒礼花炮