正文:
本章的目的是想让大家了解TypeScript中常用的一些语法以及编程方式(例如面向对象编程,面向接口编程,泛型编程以及常用的设计模式等),因此特别以面向接口的方式编写了一个Doom3(原id Software公司毁灭战士3游戏引擎)词法解析器,并且在此基础上实现了工厂模式和迭代器两种设计模式,使其支持接口的生成以及使用迭代方式进行Token解析输出。
Doom3引擎中所有的资源都存储在后缀名为.pk4的资源文件包中。该资源文件包实际就是一个zip压缩文件,因此我们可以将.pk4后缀名更改为.zip,然后就可以直接使用例如winzip程序进行解压并浏览该文件。
当我们打开pk4文件浏览后会发现一个事实:Doom3引擎中大部分的资源都是基于文本描述的(除了图片,视频,或音频等资源外),并且基于一套简单的、统一的词法规则。如何将文本按照词法规则描述解析成有意义的标记(Token)是本章的另外一个重要目的。
最后我们对XMLHttpRequest对象进行二次封装用于向服务器请求资源。这样我们就能够利用第一章中部署的lite-server服务器,将所有的存储在lite-server服务器上的文本文件,二进制文件,视频,音频传输到TypeScript(网页客户端)中进行处理。
实际上我们实现的词法解析器不单单用于Doom3引擎相关资源的解析,通过些许扩展,我们还可以支持解析各种不同格式的ASCII编码文本文件,例如Wavefront的obj模型文件以及mtl材质文件等等。
2.1 Token与Tokenizer
简单起见,我们使用js代码作为示例,来了解一下js的Token相关内容,以加深对Token的理解,代码如下:
if ( b === true )
alert ( "true" ) ;
在浏览器中输入网址:http://esprima.org/demo/parse.html后,并将上述代码黏贴到左侧文本编辑框,然后选择Tokens Tab选项,我们就会获得如下结果:
[
{
"type" : "Keyword" ,
"value" : "if"
} ,
{
"type" : "Punctuator" ,
"value" : "("
} ,
{
"type" : "Identifier" ,
"value" : "b"
} ,
{
"type" : "Punctuator" ,
"value" : ==="
} ,
{
"type" : "Boolean" ,
"value" : "true"
} ,
{
"type" : "Punctuator" ,
"value" : ")"
} ,
{
"type" : "Identifier" ,
"value" : "alert"
} ,
{
"type" : "Punctuator" ,
"value" : "("
} ,
{
"type" : "String" ,
"value": "\"true\""
} ,
{
"type" : "Punctuator" ,
"value" : ")"
} ,
{
"type" : "Punctuator" ,
"value" : ";"
}
]
通过上述代码,我们可以看到,esprima(ECMAScript词法语法解析器)会将js源码解析成Token的集合表示,每个Token具有type属性,表示该Token的类型分类,并且具有value属性,表示该Token的值是什么。
由此可见,Token(标记或记号)就是指一组不可分割的字符或字符串,它能唯一的、没有歧义的标记出一种状态。本质上来说,就是特殊的字符或字符串(例如if、=== 等)。而esprima则是Tokenizer,其作用是将字符串表示的js源码数据读取进来,按照预先设定的标准进行分类处理,处理的结果就是Token。
事实上esprima是一个ECMAScript解析器,包含词法解析和语法解析,最终会将js源码解析成抽象语法树(Abstract Syntax Tree,简称AST),而在这里,为了演示的原因,我们仅仅使用了esprima的词法解析功能,并没有使用到语法解析功能。
2.1.1 Doom3文本文件格式
如果要实现一个特定文件格式的词法解析器,一定要了解该文件的词法特征,根据文件的词法特征抽象出分类规则,然后才能编码实现词法解析功能。因此本小节来了解一下Doom3引擎中的文本文件的相关规则,使用如下一段具有普遍性的文本字符串:
numMeshes 5
/*
* joints关键字定义了骨骼动画的bindPose
*/
joints {
"origin" -1 ( 0 0 0 ) ( -0.5 -0.5 -0.5 )
"Body" 0 ( -12.1038131714 0 79.004776001 ) ( -0.5 -0.5 -0.5 ) // origin
}
例如numMeshes、joints等没有双引号的单词,作为关键字处理,也就是Doom3引擎预先定义好的,具有特定含义的一些词,它们具有唯一性,不可更改性。
例如 "origin"、"Body"这些具有双引号的单词,作为标识符处理,这些标识符并非由Doom3引擎预先定义,而是美术设计等相关人员或者模型制作动画师定义的名称。
在/*和*/之间的文字被Doom3引擎的词法解析器视为注释,和TypeScript一样,表示多行注释。斜杠//后的文字则被视为单行注释,这也和TypeScript的单行注释保持一样性。
大括号对{ }表示一个块状模块,你可以将其视为一个区块分组,和TypeScript作用域类似。小括号对( )内部是使用浮点数表示的矢量或矩阵数据,你可以将其看成数组表示,需要注意的一点是,数组元素之间不是使用逗号分隔,而是使用空格符号进行分隔。
Doom3文本文件中的数据类型其实就两种:字符串和数字,其中关键字和标识符都可以看成字符串类型,而数字可以分为整数和浮点数两种类型。
上述几条,基本囊括了Doom3文本文件格式的关键之处,还有一些隐藏在深处的规则,则由源码实现的过程中进行描述。
2.1.2 使用IDoom3Token与IDoom3Tokenizer接口
先来看一下如何调用Doom3的词法解析器,然后再去了解如何实现过程。首先创建一个名为doom3TokenizerTest . ts的文件,并导入如下四个结构:
import { IDoom3Token , IDoom3Tokenizer , Doom3Factory , ETokenType } from " ./src/doom3Tokenizer" ;
然后将要解析的字符串赋值给一个string类型的变量,需要注意的是我们使用了ES6中的模板字符串(使用了开单引号`xxx`,而不是双引号"xxx"或单引号'xxx'的方式来定义字符串字面值):
let str : string = ` // 注意:这是开单引号`,不是单引号'
numMeshes 5
/*
* joints 关键字定义了骨骼动画的 bindPose
*/
joints {
"origin" -1 ( 0 0 0 ) ( -0.5 -0.5 -0.5 )
"Body" 0 ( -12.1038131714 0 79.004776001 ) ( -0.5 -0.5 -0.5 ) // origin
}
` ; // 注意:这是开单引号`,不是单引号'
最后来看一下如何使用IDoom3Token和IDoom3Tokenizer的属性和方法,具体代码如下:
// 从Doom3Factory工厂创建IDoom3Tokenizer接口
let tokenizer : IDoom3Tokenizer = Doom3Factory . createDoom3Tokenizer ( ) ;
// IDoom3Tokenizer接口创建IDoomToken接口
let token : IDoom3Token = tokenizer . createDoom3Token ( ) ;
//设置IDoom3Tokenizer要解析的数据源
tokenizer . setSource ( str ) ;
// getNextToken函数返回ture,说明没有到达字符串的结尾,仍有token需要解析
// 解析的结果以传引用的方式从参数token传出来
// 如果getNextToken返回false,说明已经到达字符串结尾,则停止循环
while ( tokenizer . getNextToken ( token ) ) {
//如果当前的token的type是Number类型
if ( token . type === ETokenType . NUMBER ) {
console . log ( " NUMBER : " + token . getFloat ( ) ) ; //输出该数字的浮点值
} else if ( token . isString ( "joints" ) ) {
//如果当前token是字符串类型,并且其值为joints,则输出
console . log ( " 开始解析joints数据 " ) ;
}
else { //否则获取当前token的字符串值
console . log( " STRING : " + token . getString ( ) ) ;
}
}
我们使用F5快捷键启动VS Code的调试器,会看到在浏览器的控制台中输出如图2.1所示的内容。
我们会看到每个关键字(例如numMeshes),标识符(例如origin)以及数字(浮点数,整数以及负数)被正确的输出,并且跳过多行注释和单行注释中的内容。代码中对于joints关键字则进行了特殊处理,因此并没有输出。除此之外,还将左右小括号和左右大括号都作为单独一个Token输出到控制台。
如果我们想重新解析整个字符串,那么我们可以使用IDoom3Tokenizer接口的reset方法,该方法会将当前索引设置到字符串的首位,这样继续循环调用getNextToken就可以重新解析整个字符串。
我们也可以使用IDoom3Tokenizer的setSource方法重设要解析的字符串(另外一个字符串),setSource方法内部也会将当前索引重置到字符串的首位。
2.1.3 ES6中的模板字符串
在此强调一下,使用ES6中的模板字符串是很不错的一种体验。笔者认为ES6模板字符串一个最大的优点是:可以一次定义多行字符串,并且保证空格和缩进,而单引号或双引号只能定义单行字符串。让我们来看一下笔者以前经常使用的定义多行字符串的三种方式。
1、在一行字符串中添加\n转义换行符,这种方式耗时又容易出错,具体代码如下所示:
let str1 : string = " numMeshes 5 \n /** \n * joints关键词定义了骨骼动画的bindPose \n */ \n joints { \n 'origin'-1 ( 0 0 0 ) ( -0.5 -0.5 -0.5 ) \n 'Body'0 ( -12.1038131714 0 79.004776001 ) ( -0.5 -0.5 -0.5 ) // origin \n } " ;
2、使用+=符号拼接字符串,代码如下所示:
let str2: string = " numMeshes 5 " ;
str2 += " /* " ;
str2 += " * joints关键词定义了骨骼动画的bindPose " ;
str2 += " */ " ;
str2 += " joints { " ;
str2 += " 'origin' -1 ( 0 0 0 ) ( -0.5 -0.5 -0.5 ) " ;
str2 += " 'Body' 0 ( -12.1038131714 0 79.004776001 ) ( -0.5 -0.5 -0.5 ) // origin " ;
str2 += " } " ;
3、使用数组方式并调用join函数将数组拼接成字符串,这种方式相对来说比较清晰,代码如下所示:
let str3: string = [
" numMeshes 5 " ,
" /* " ,
" * joints关键词定义了骨骼动画的bindPose " ,
" */ " ,
" joints { " ,
" 'origin' -1 ( 0 0 0 ) ( -0.5 -0.5 -0.5 ) " ,
" 'Body' 0 ( -12.1038131714 0 79.004776001 ) ( -0.5 -0.5 -0.5 ) // origin " ,
" } "
] . join( " \n " ) ;
需要注意的一点是,这三种方式中处理字符串中的子字符串(例如orgin和Body),需要使用单引号引起来。
如果不考虑兼容性,并且你当前的JavaScrit引擎支持ES6标准的话,那么能使用模板字符串的话就尽量使用模板字符串吧。当然模板字符串还支持以${ }方式定义变量,具体的用法请自行查阅TypeScript或ES6官方手册。
2.1.4 IDoom3Token与IDoom3Tokenizer接口的定义
可以使用TypeScript的interface关键字来定义接口,具体代码如下所示:
export interface IDoom3Token {
reset ( ) : void ;
isString ( str : string ) : boolean ;
readonly type : ETokenType ;
getString ( ) : string ;
getFloat ( ) : number ;
getInt ( ) : number ;
}
关于IDoom3Token接口中大部分方法的应用,在上一节的代码中有演示,还是比较简单的。这里看一下type这个只读属性,该属性使用了readonly声明,意味着其值只能被读取,不能被更改。同时type的数据类型为ETokenType,是一个枚举类型。在TypeScript中,我们可以使用enum关键字来定义枚举类型,具体代码如下所示:
export enum ETokenType {
NONE , // 0 default情况下,enum定义的枚举值是以0开始的数字类型
STRING , // 1 表示字符串类型
NUMBER // 2 表示数字类型
}
最后看一下IDoom3Tokenizer的接口定义,代码如下所示:
export interface IDoom3Tokenizer {
setSource ( source : string ) : void ; //设置要解析的字符串
reset ( ) : void ; // 重置当前索引为0
getNextToken ( token : IDoom3Token ) : boolean ; // 获取下一个token
}
今天的内容到此结束,明天我们来解析如何以面向接口的方式实现IDoom3Tokenizer相关的接口方法。
3D篇:使用安德斯海尔斯伯格的TypeScript语言来演示约翰卡马克的Quake3/Doom3引擎相关内容!
决定弄个2D/3D书籍相关的群,群名和群号如下所示:
TypeScript图形渲染实战2D/3D架构设计与实现(689569128)
本人有一游戏行业与招聘方面的群,现有群友1700多群友,坑位不多了,若想找工作或HR及猎头,可以加入如下群:
游戏研发招聘群(232183160)
本人微信公众号如下所示: