浏览器 canvas 是一个很好的 API,性能非常好,我们有时用它来解决网页上绘图的需求,但有时它好像不那么好用,尤其是随着绘图需求的变化,代码量会越积越多,如果对代码没有很好的规划和设计,就难免显得杂乱。
今天演示几个常见的使用场景,如果你正好碰到了类似的需求,就可以参考这篇文档 引入使用。在开始之前,先说明一下 escher-canvas 不适用于什么场景:
- 不适合静态场景,除非手动暂停,否则 escher-canvas 无时无刻都在绘制
- 不适合数据表格和数据可视化的需求,市面上已经有很成熟的解决方案
- 不适合 3D 绘图需求
打砖块
一个经典的游戏场景,我们先做一个小球,并通过按钮启动它。
场景非常简单:一个小球在向右下角移动。我们逐个拆解这个场景,来看看 escher-canvas 是如何帮助开发者简单写出这个程序的,示例代码放在 Github 的仓库内。
首先,我们要知道 canvas 是如何画图的,流程大概是这样:
- 拿到 canvas dom 上下文 context
- 通过 context 调用对应的 API,完成绘制
但这样的绘图显然无法完成我们这个砖块移动的需求,在我们这个例子里,流程是这样
- 拿到 canvas dom 上下文 context
- 通过 context API,绘制第 1 帧画面
- 擦除第 1 帧画面
- 绘制第 2 帧画面,如此循环往复
帧的概念很好懂,每秒产生多少个画面,就是每秒多少帧,单位是 fps,电影通常是 24fps,以前的游戏是 30fps,但现在随着设备性能增长,大家最低要求也是 60fps 了,现在的主流显示器大多也是以 60 帧刷新率运行。
绘制一个动画界面
要绘制小球,假设游戏以 30 帧的速度运行,我们就需要将一秒拆分成 30 帧,对应小球在 30 帧里面的各个状态,并且每次按照指定的状态绘制即可,举个例子:
- 第 1 帧小球的坐标是 [1, 1]
- 第 2 帧小球的坐标是 [2, 2]
- 第 3 帧小球的坐标是 [3, 3]
- 第 4 帧小球的坐标是 [4, 4]
小球大概在以每帧 1 像素的速度向右下方移动。知道了这个原理实现代码就是体力活了,下面这段 React 代码可以绘制这个画面:
代码大概如下(摘要)
import React from "react"
const BallMove = () => {
let canvas = React.useRef()
let [frame, setFrame] = React.useState(30)
React.useEffect(() => {
// 如果你不熟悉 react hooks,
// 这行可理解为 let dom = document.querySelector("canvas")
let dom = canvas.current
let context = dom.getContext("2d")
context.clearRect(0, 0, 300, 200)
context.beginPath()
context.arc(frame, frame, 20, 0, 2 * Math.PI)
context.fill()
}, [frame])
React.useEffect(() => {
let timer = setInterval(() => {
setFrame(frame => {
if (frame < 150) {
return frame + 1
} else {
clearInterval(timer)
return frame
}
})
}, 1000 / 30)
}, [])
return (
<div className="w-[300px] mx-auto relative">
<canvas
ref={canvas}
className="border-2 w-[300px] h-[200px]"
width={300}
height={200}
/>
</div>
)
}
但,这个代码肯定是不行的! 为了让程序跑起来,我们写死了很多的逻辑,并且在一个小小的 Hooks 组件里面维护了太多东西:
- canvas 和上下文
- 帧率
- 小球的绘制逻辑
- 每一帧的小球
乱成一团的组件非常不利于维护,并且也很难加入一些复杂逻辑(例如边界判定、碰撞检测),我们需要用更好的方式实现这个功能。
escher-canvas 绘制
为了让程序组织有序,我们需要重新想一想这个组件的目的,并且稍微设计一下这个体系:
- canvas 所在的地方是入口,里面应该包含所有物体
- 类似小球这样的物体只应该被 “放到” canvas 里面,然后尽量在放完之后就不管了
- 类似帧率、自动刷新这样的事应该交给一个控制器去管理,而不应该用 React 的状态实现
为了做到这些事,我们将使用 escher-canvas 工具来实现这个体系
npm install escher-canvas
# or use yarn
yarn add escher-canvas
安装完毕后,将代码改写:
// react render
import React from "react"
import { Scene } from "escher-canvas"
import { Ball } from "./ball-model"
const EscherBall = () => {
let scene = React.useRef()
let canvas = React.useRef()
React.useEffect(() => {
let dom = canvas.current
let context = dom.getContext("2d")
scene.current = new Scene()
// 👇 自动注册 canvas,但也可以选择手动注册
scene.current.autoRegisterCanvas()
// 👇 设置帧率,可以设置无上限
scene.current.setFps(30)
// 👇 注册连续渲染,开启之后就会自动渲染,无需手动管理
scene.current.registerContinuousRendering()
// 👇 创建 Ball 实例,只需要将 ball 交给 scene 后面就可以不管
let ball = new Ball()
scene.current.registerObject(ball)
}, [])
return (
<div className="w-[300px] mx-auto relative">
<canvas
ref={canvas}
className="border-2 w-[300px] h-[200px] bg-gray-500"
width={300}
height={200}
/>
</div>
)
}
上面的代码只做了两件事:
- 创建一个 Scene 对象,并且设置了一些帧率之类的配置
- 创建一个 Ball,将 Ball 交给 Scene
至于 ball 本身是如何运行的,只需要编写一个简单 ball.js
文件即可
// ball.js
import { ObjectPrototype } from "escher-canvas"
// ObjectPrototype 是 escher-canvas 提供的物体封装基类
// 继承它并实现 update, draw 方法就可以稳定绘制任何物体
export class Ball extends ObjectPrototype {
constructor(options) {
super(options)
this.position = { x: 30, y: 30 } // 初始坐标
}
// context 是 canvas 的绘图上下文,所有标准绘图方法都可用
draw(context) {
let { x, y } = this.position
context.clearRect(0, 0, 300, 200)
context.beginPath()
context.arc(x, y, 20, 0, 2 * Math.PI)
context.fill()
}
update() {
let { x, y } = this.position
if (x < 150 && y < 150) {
this.position.x += 1
this.position.y += 1
}
}
}
Ball 里面引入了两个很重要的概念:update
和draw
,本身很简单
- update 总是在每一帧将数据设置好
- draw 总是在每一帧读取数据,并按照制定的方式绘制
站在物体的角度,我们就只需要设定好 ball 如何“更新、绘制” 它就可以自行绘制了。
调整砖块数值
现在,我们为小球增加一个「碰到边缘就自动弹回」的机制:
实现代码如下:
export class Ball extends ObjectPrototype {
constructor(options) {
super(options)
this.position = { x: 30, y: 30 }
this.speedX = 3
this.speedY = 3
}
draw() {
...
}
update() {
let { x, y } = this.position
if (x > 300 - 20 || x < 20) {
this.speedX = -this.speedX
}
if (y > 200 - 20 || y < 20) {
this.speedY = -this.speedY
}
this.position.x += this.speedX
this.position.y += this.speedY
}
}
只需要修改 update 代码就可以做到,这就是逻辑分离的好处。使用 escher-canvas 可以让开发者更容易做到逻辑分离,不管 Ball 里面多复杂,外部永远只需要创建、添加即可,根本不必在意内部的细节,关注点分离后开发会更轻松。
完
这一篇分享了如何在 escher-canvas 里面创建一个基本物体并使其运动,这比直接使用原生 canvas 的方式要简单很多。
下一篇(待续),我们会分享一个更加复杂的例子,如果涉及到多个物体的相互运动,我们可以怎么做?