两年前,我曾徒手写过一个运行在 Web 端的小游戏,就是用 Canvas 来实现的,以后便几乎从未与 Canvas 打交道,这两天偶然接触到一本书《TypeScript图形渲染实战:2D架构设计与实现》,又再次让我对这方面产生了兴趣,同时这本书采用的 TypeScript 实现也正合我意,便阅读一番,跟着敲了敲,感受收益颇多,因而想整理如下发出来,让你们也看看。html
正文从这里开始:webpack
凡是涉及到 Canvas, 通常都是进行 2D 或者 3D(WebGL) 来绘制动态场景,帧动画在 Canvas 上的实现就是维持一个主要的帧循环,在帧函数中作擦除和从新绘制的操做,除了主要的帧循环以外,还有一些其余功能,好比对用户输入事件的分发和响应,计时器、帧率计算等等。这么多的功能若是用面向过程的形式,会致使代码结构比较混乱,没法高度复用,而封装成一个 Application 类则能够将功能和流程封装起来,将可变的部分提供给第三方使用,很是方便,并且用 TypeScript 实现很酸爽。git
实际上,不少游戏引擎或类库的入口都会命名为 Application
。github
我也是刚接触 TypeScript,自我感受书中的搭建开发环境的步骤和结果不太理想,不合本身口味,便在 TypeScript 找到了 TypeScript-Babel-Starter 这个模版库,而后搭配 webpack 稍微配置一下,一句命令 npm run bundle
就实现了即时编译成页面直接引用可用的 bundle 文件的功能,若是你想试试跟着写一写,环境可直接参考个人仓库:web
对于 TypeScript 语法相关的前置知识,我也没正经学过,去官网稍微瞄一眼文档,就跟着上手写了,遇到高级的知识再回过头去了解吧。canvas
环境搭建好以后,即可以开始分析并实现 Application 类了。数组
前文提到,Application 类是对流程和功能的封装,那咱们先来分析一下这个类具体要实现哪些功能:bash
export class Application {
protected _start: boolean = false
protected _appId: number = -1
protected _lastTime!: number
protected _startTime!: number
private _fps: number = 0
public canvas: HTMLCanvasElement
public constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas
}
public start(): void {
if (!this._start) {
this._start = true
this._appId = -1
this._lastTime = -1
this._startTime = -1
this._appId = requestAnimationFrame(this.step.bind(this))
}
}
public stop() {
if (this._start) {
cancelAnimationFrame(this._appId)
this._appId = -1
this._lastTime = -1
this._startTime = -1
this._start = false
}
}
public isRunning(): boolean {
return this._start
}
public get fps(): number {
return this._fps
}
/**
* step 基于时间的更新和重绘
*/
protected step(timeStamp: number): void {
if (this._startTime === -1) this._startTime = timeStamp
if (this._lastTime === -1) this._lastTime = timeStamp
// 计算当前时间点距离第一次调用时间点的差值
let elapsedMsec: number = timeStamp - this._startTime
// 计算当前时间距离上一次调用时间点的差值
let intervalSec: number = timeStamp - this._lastTime
// 计算fps
if (intervalSec !== 0) {
this._fps = 1000 / intervalSec
}
// 将 intervalSec 化为秒
intervalSec /= 1000
// 更新上一次调用的时间点
this._lastTime = timeStamp
// 更新
this.update(elapsedMsec, intervalSec)
// 渲染
this.render()
// 递归调用
this._appId = requestAnimationFrame(
(elapsedMsec: number): void => {
this.step(elapsedMsec)
}
)
}
// 更新,由子类覆写
protected update(elapsedMsec: number, intervalSec: number): void { }
// 渲染,由子类覆写
protected render(): void { }
}
复制代码
首先,咱们声明了一个 Application
的类,从代码中咱们可以了解到如下几点:架构
_appId
类型为 number
, 值为 requestAnimationFrame
方法的返回值,用来在中止动画时取消循环;_fps
属性表明帧率,每秒播放的帧数,在这里很容易计算,1s / intervalSec
,并定义了 getter 属性来获取到 fps
属性;start
和 stop
方法来实现动画的开始和中止,具体的实现细节也很简单;update
和 render
两个虚方法,将会被子类 Override 覆写,以实现具体的更新和渲染逻辑;在这里暂时只处理鼠标事件和按键事件,对事件的分发响应的原理就是当监听到事件触发时,根据不一样的事件类型,来作响应的处理,而具体的响应处理通常不禁 Application 类提供,而是子类本身提供。
若是监听到事件呢?固然是 addEventListener
接口。在 Application 类中咱们可以取到 canvas 元素,即可以在构造函数中,对此元素监听鼠标事件:
this.canvas.addEventListener('mousedown', this, false)
this.canvas.addEventListener('mouseup', this, false)
this.canvas.addEventListener('mousemove', this, false)
复制代码
对于按键事件只能在 window
上监听:
window.addEventListener('keydown', this, false)
window.addEventListener('keyup', this, false)
window.addEventListener('keypress', this, false)
复制代码
而后,咱们注意 addEventListener
接口传递的参数,第一个为事件类型的字符串,第二个必须为一个实现了 EventListener 接口的对象,或者是一个函数。
很明显这里咱们传递了 this
,也就是这个类,那这个类就必须实现了 EventListener 接口,即须要一个 handleEvent
方法来接收事件做为参数进行处理。
public handleEvent(evt: Event): void {
switch (evt.type) {
case 'mousedown':
this.dispatchMouseDown()
break
case 'mouseup':
this.dispatchMouseUp()
break
case 'mousemove':
this.dispatchMouseMove()
break
case 'keypress':
this.dispatchKeyPress()
break
case 'keydown':
this.dispatchKeyDown()
break
case 'keyUp':
this.dispatchKeyUp()
break
default:
break
}
}
复制代码
以上处理经过 switch
来根据事件类型来执行相应的方法,这些方法都会由子类自由覆写,固然在实现中还有一个 CanvasInputEvent
类以及继承它的两个子类,CanvasMouseEvent
和 CanvasKeyBoardEvent
分别表明鼠标事件和按键事件的封装,支持识别同时按住 ctrl
、alt
、shift
移动鼠标或按下其余键,具体实现请看 event.js。
export class Canvas2DApplication extends Application {
protected context2D: CanvasRenderingContext2D | null
constructor(canvas: HTMLCanvasElement) {
super(canvas)
this.context2D = this.canvas.getContext('2d')
}
}
复制代码
export class WebGLApplication extends Application {
protected context3D: WebGLRenderingContext | null
constructor(canvas: HTMLCanvasElement, contextAttributes?: WebGLContextAttributes) {
super(canvas)
this.context3D = this.canvas.getContext('webgl', contextAttributes)
// 检查webGL兼容性
if (this.context3D === null) {
this.context3D = this.canvas.getContext('experimental-webgl', contextAttributes)
if (this.context3D === null) {
throw Error('没法建立WebGLRenderingContext上下文对象')
}
}
}
}
复制代码
Application 类中用了 requestAnimationFrame
来驱动动画不停更新和重绘,但有的时候可能有些任务不须要不停地重绘,只须要隔一段时间执行一次或者只会执行一次,这个时候就须要实现一个计时器了。
虽然能够直接使用 setTimeout
或者 setInterval
,但仍是跟着书中在基于时间的重绘上实现了一个“不精确”的计时器功能。
实现原理也很简单,在 Application
类中维护一个 timers
数组,一个用于惟一从0开始自增的 _timerId
,同时实现了 addTimer
方法来新增一个定时器,removeTimer
方法来移除一个定时器,以及一个 _handleTimers
方法来在 step
函数中调用,执行定时器的回调。
随意编写了一个 index.html
和 index.ts
文件来进行测试,不断的画出当前的 _appId
,事件可以正确响应,计时器也能正常执行,而且全部的操做都是在 Canvas2DApplication
的子类上进行的,很好的进行了封装和多态,可移植性和维护性很强,写起来也很是舒服。
若是你也想试试,能够看一看这里:
本文记录了实现一个 Application
类的过程,其中不少细节都被省略,只能在代码中看到具体实现,写的过程当中自我感受学到了许多,不枉花时间去跟着实践。本文也同步发布在「端技」公众号,欢迎来玩👏
下一部分会是具体的图形渲染相关的知识,后面还有不少点值得探究,下次再见!