在上一篇教程介绍了SurfaceView的使用,也实现了一个动画循环,接下来就可以在这个循环体内绘制游戏的画面,由于我们绘制的只是游戏的一帧,所以用时不能太长,要讲究点效率,这里我们可以使用LruCache对已经绘制过的图像进行缓存,LruCache其实就是一个包装了LinkedHashMap的键值对集合,图像被缓存后可以直接从集合中取出,免去了反复重绘的时间。为了便于使用我们封闭了一个工具类:
object BmpCache {
private val mapCache = LruCache(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方法渲染到屏幕上。在开始之前先说明几个绘图术语:
- 原点坐标:位于SurfaceView的左上角,X轴为0,往右递增,Y轴为0,往下递增。如果SurfaceView的分辨率为1920*1080时,原点坐标为(0,0),右下角坐标为(1919,1079)。
- 旋转角度:以三点钟方向为0度,顺时钟递增,六点为90度,九点为180度,十二点为270度。
- 笔刷类Paint:Paint类的主要作用是为Canvas绘制的图形设置样式,例如:图形的颜色,边框的粗细和密闭区的填充模式、颜色和着色。
- 多边形路径类Path:Path类即可以为图形指定动画路径也可以用来画多边形,如果是画多边形的话一定要将多边形闭合
至此我们可以正式的开始绘画了,为了便于讲解就以《空间大战》里的玩家飞船为例,首先是创建一个Bitmap对象并在上面画出飞船的模样,画完后立马缓存起来,然后在绘制动画帧的时候再从缓存中取出并画到屏幕上。
绘制并缓存的流程如下:
- 创建一个Bitmap对象。
- 创建一个Canvas对象,并把Bitmap对象作为参数传给Canvas的构造方法。
- 利用Canvas对象在Bitmap上画出玩家飞船的样子。
- 对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对象画到屏幕指定的位置上的流程:
- 从缓存中取出玩家的Bitmap,如果没有则绘制并缓存。
- 用Canvas的drawBitmap方法绘制到Surface的指定位置上。
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群聊:口袋里的安卓