Android游戏教程:Bitmap、Canvas、Paint的那些事

在上一篇教程介绍了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对象并在上面画出飞船的模样,画完后立马缓存起来,然后在绘制动画帧的时候再从缓存中取出并画到屏幕上。

绘制并缓存的流程如下:

  1. 创建一个Bitmap对象。
  2. 创建一个Canvas对象,并把Bitmap对象作为参数传给Canvas的构造方法。
  3. 利用Canvas对象在Bitmap上画出玩家飞船的样子。
  4. 对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对象画到屏幕指定的位置上的流程:

  1. 从缓存中取出玩家的Bitmap,如果没有则绘制并缓存。
  2. 用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群聊:口袋里的安卓

你可能感兴趣的:(Android游戏教程:Bitmap、Canvas、Paint的那些事)