兩年前,我曾徒手寫過一個運行在 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
類的過程,其中不少細節都被省略,只能在代碼中看到具體實現,寫的過程當中自我感受學到了許多,不枉花時間去跟着實踐。本文也同步發佈在「端技」公衆號,歡迎來玩👏
下一部分會是具體的圖形渲染相關的知識,後面還有不少點值得探究,下次再見!