使用 escher-canvas 绘制运动场景

浏览器 canvas 是一个很好的 API,性能非常好,我们有时用它来解决网页上绘图的需求,但有时它好像不那么好用,尤其是随着绘图需求的变化,代码量会越积越多,如果对代码没有很好的规划和设计,就难免显得杂乱。

今天演示几个常见的使用场景,如果你正好碰到了类似的需求,就可以参考这篇文档 引入使用。在开始之前,先说明一下 escher-canvas 不适用于什么场景:

  • 不适合静态场景,除非手动暂停,否则 escher-canvas 无时无刻都在绘制
  • 不适合数据表格和数据可视化的需求,市面上已经有很成熟的解决方案
  • 不适合 3D 绘图需求

打砖块

一个经典的游戏场景,我们先做一个小球,并通过按钮启动它。

场景非常简单:一个小球在向右下角移动。我们逐个拆解这个场景,来看看 escher-canvas 是如何帮助开发者简单写出这个程序的,示例代码放在 Github 的仓库内。

首先,我们要知道 canvas 是如何画图的,流程大概是这样:

  1. 拿到 canvas dom 上下文 context
  2. 通过 context 调用对应的 API,完成绘制

但这样的绘图显然无法完成我们这个砖块移动的需求,在我们这个例子里,流程是这样

  1. 拿到 canvas dom 上下文 context
  2. 通过 context API,绘制第 1 帧画面
  3. 擦除第 1 帧画面
  4. 绘制第 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 绘制

为了让程序组织有序,我们需要重新想一想这个组件的目的,并且稍微设计一下这个体系:

  1. canvas 所在的地方是入口,里面应该包含所有物体
  2. 类似小球这样的物体只应该被 “放到” canvas 里面,然后尽量在放完之后就不管了
  3. 类似帧率、自动刷新这样的事应该交给一个控制器去管理,而不应该用 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> ) }

上面的代码只做了两件事:

  1. 创建一个 Scene 对象,并且设置了一些帧率之类的配置
  2. 创建一个 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 里面引入了两个很重要的概念:updatedraw,本身很简单

  • 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 的方式要简单很多。

下一篇(待续),我们会分享一个更加复杂的例子,如果涉及到多个物体的相互运动,我们可以怎么做?