Thin框架的应用(一):单机双人对战象棋程序

295 查看

使用JavaScript创建模块化的双人对战象棋程序

1. 关于这篇文章

2004年,我花两天时间,用JavaScript和VML创建了一个单机双人象棋,并且作了简短的分析。在那个时代,没有AngularJS,没有BackBone,没有所有这些前端MV*框架。甚至没有jQuery,没有prototype,没有mootools,因此没有什么可借鉴的模块划分方式。我只好用很原始的办法,做了一种伪继承,实际是组合,来实现棋子和棋局之间的关系。

现在是2013年,9年过去了,Web的世界早已不是过去的样子,开发方式发生了翻天覆地的变化,我们有了Gmail,有了Google docs等等把Web技术应用到极致的优秀产品,有了asm.js、pdf.js等等让我们目瞪口呆的技术,更催生了各种MV*框架的兴起,我们有更多,更强大的方式去写Web程序。

前一段时间,我创建了一个简单的JavaScript框架叫做thin,实现了模块的定义、异步加载和使用,并且为它写了一个比较简短的Demo,但这个Demo实在太简单了,当一个应用更大、更复杂的时候,我们应该如何组织自己的程序呢?

为了说明我们这个简单框架的能力,我把之前写过的这个象棋在这个thin框架基础上重写一遍,并且作更深入的分析,以便使一些入门不久的读者得到帮助,同时也顺便检验我的新框架模块化是否是可用的。

另外一个方面,我们看到VML已经彻底衰落了,各种基于SVG和Canvas的绘图技术取代了它,因此在本例中,我们也与时俱进,改用SVG来绘制棋盘和棋子。RaphaelJS是一个很好的跨平台绘图库,它封装了SVG和VML,在能够使用SVG的浏览器中,它用SVG绘图,否则尝试使用VML,对于上层应用,操作绘图的API是毫无区别的,开发者不会感知到它的具体实现差别。

之前我写过一些文字,用于探讨软件开发模块的划分原则,感觉它们放在这篇文章里非常合适,所以略微修改之后,加了进来。

2. 模块划分的一些原则

2.1. 面向对象

面向对象可以算是老生常谈了,在现代软件开发中,它是个主流的选择,相对于面向过程,有一些改进。

假设我们是上帝,要创造世界,因为这个过程太过复杂,无从入手,所以先从一件简单的事情看起。现在我们要设计一个方法,用于描述狼吃羊这个事情,某只狼吃了某只羊,你可以面向过程地吃,eat(狼A, 羊A),也可以面向对象地吃,狼A.eat(羊A)。差别在哪里?只是写法有点变化。

好,那么我们帮上帝模拟整个生物界,这里面很多东西可以吃,大鱼吃小鱼,小鱼吃虾米,吃不吃皮,吐不吐骨头,这个时候再来修改这个eat函数,复杂吗?eat里面要判断很多东西,假如上帝很勤劳,所有代码都自己设计,那没关系,没太大区别,判断就判断呗。

假设上帝没足够精力来管理整个东西了,雇了一群天使来协助设计,每个人都来修改这个eat函数,当然可以拆分,wolfEatSheep(), tigerEatWolf(),然后在eat里面判断参数来分别调用,把函数分下去让每个人做,可以。

动物不光要做吃这个事情,要能跑能跳,会说会叫,又多了一堆函数,每个里面都这么判断,相当相当的烦。怎么办?我们来面向对象一下。

现在开始按照动物拆分,100个天使,每个天使创造一种动物。创造哪种动物,就站在哪种动物的角度考虑问题,我吃的时候怎么吃,跑的时候怎么跑,都跟别人无关,这么一来,每个人就专注多了。每个动物只关注我要怎么才能活着,不必站在上帝的角度考虑问题。这个过程,是类的划分过程,也就是封装的过程。

这时候,上帝觉得自然界光有动物是不行的,还要有植物,刚才说的这些都是动物,植物的特点跟动物有很大区别。假设你是上帝,为每种生物安排衣食住行,那是相当复杂的。偷懒吧,上帝说,植物们,你们自己生长吧,动物们,你们吃喝玩乐吧,假如能达到这个效果,那很省事。

上帝用一个循环来遍历所有动物,让他们吃喝玩乐,用另外一个循环让植物欣欣向荣。动物跟植物为什么要区别对待?因为它们不是同样的东西,能做的事情不同。所有动物派生于动物这个基础类型,从动物这个种类下,又分出各种纲,各种目,各种属。狮子是哺乳动物,猴子也是,但是狮子是猫科动物,猴子是灵长动物,这就构成了一个倒着的树状体系,一层一层形成继承关系。哺乳动物会喂奶,那么所有继承自哺乳动物的,都自动拥有这个特征。整个这一切,构成了继承链。

假设有一天由于变异出现了新物种,不必劳烦上帝关照,只要鉴别一下它属于什么类型,就知道能做什么事了,它的一举一动,都必然拥有它所继承的种类的特征。

这样就能描述生物界了吗?不,还有那么一些怪胎的存在。你认为哺乳动物都不会飞,那就错了,因为蝙蝠会飞。蝙蝠会飞是它自身的特性,并非继承自哺乳动物,但是“飞”这个动作,却非蝙蝠独有。如果把“飞”定义成接口,那就很美好了,蝙蝠实现了它的飞行接口,虽然内部实现跟鸟类有所不同,而且这并不影响它的哺乳动物特性。

总之,是否面向对象只是思维方式的不同。做一个软件,面向对象也能做,不面向对象也能做。我的观点,如果关注可维护性和协作性,从目前的角度,面向对象是很好的选择,它很自然,很优雅,优雅得只要打一个“.”,你就能想起来什么事能做,什么事不能做。

2.2. 模块的职责划分

面向对象的一个基本原则是分而治之(Divide and Conquer),这种方法论提倡将程序模块化,各模块实现单独的功能,在统一的管理下协同工作,构成整个系统。

在具体实施的时候,又有两种倾向:将功能高度集中于主控制模块;将功能下放到各部件。这两种做法都有很高的可行性,也分别有大量支持者。我觉得在一些程度上,后者更贴近人类的思维方式,更适合用人性化的观念来解释。

将两种类型的程序对应到生物集群,第一种相当于一个蚁群,第二个相当于人群。蚁群的特点是,个体能够完成的事务非常有限,但是因为在一个非常强有力的统治者蚁后的控制下,它们能够协同工作,统一调度,完成不可想象的事件。人群的特点是,每个人都可以独立思考,能够理解别人的指令,并且根据这些指令做到力所能及的事情。作为人群的统治者,他的智慧不需要比其他人的高太多,只需要从宏观上来把握一些事情即可。

从系统的实现来说,第一种方式难度很高。完成单个蚂蚁(小模块)的功能并不复杂,创建大量的蚂蚁也只不过是需要的时间多一点,但是,当开始设计蚁后(总控模块)的时候,噩梦开始了,整个调度算法实在是一件令人头疼的事情。对于比较复杂一点的系统,让一个人去设计这个模块简直是不可思议,但是如果由多个人共同完成这个模块,又面临着互相理解的问题,每个人的思路都不相同,在努力协作的过程中,大量的时间被浪费在交流和意见的统一上。与此同时,制作蚂蚁的程序员日益烦躁,觉得自己的工作没有难度,无聊,士气低下……

换一种思路,从人类管理的角度来看问题。假设有一支庞大的军队(假设是一个集团军),司令官需要他的士兵列队,我们来为这个系统设计调度算法。先假设所有士兵跟蚂蚁一样笨,他们只能明白“站到司令部大门往东50米,往北100米的地方”这样的简单指令,请同情一下这位司令官,他不得不为每个士兵来指定一个位置,并且不得不研究列队的规则,他需要整天忙碌来完成这样一个庞大的任务(而且还不一定能完成)。他叹息道:哦,上帝……

让我们设法来减轻他的烦恼吧,目标是让每个人都主动参与这个事件,不再那么被动,大家都努力完成自己力所能及的工作。于是我们授权各级指挥官让他自己的士兵列队,这样一来,司令官的工作简单多了,他发布命令:各位军长请注意,我命令你们列队,按照番号顺序,分布到司令部门口的空地上(假设这个空地足够大,姑且认为能够容纳整个集团军),各军之间保持50米间隔。

接到命令以后,军长们开始忙碌,而司令官先生已经可以搬一把椅子坐到电话机旁,等待列队完毕的报告了。同样,军长要做的事情,也就是告诉属下的各位师长,让他们按照番号顺序列队,就这样,命令被传递到最下面一级。班长大喊:伙计们,按照个头排成一列,矮的在前面,高的在后面,前后间隔一米!于是,所有人站到了他应该站的位置,望着在短时间内迅速列队的整个集团军,司令官太满意了。

我们发现了什么?很显然,下放权力的方式要省事得多,更关键的是,它使得每个人都做一定的事情,但是又不成为负担。在设计者思路清晰化的同时,负责为系统每个部分编写代码的人员也更容易享受到编程的乐趣,就算是最低层的程序员也有了发挥自己才能、用自己的思路去影响系统的机会,而且,系统集成的过程将变得更加简单。

对于一名软件设计师来说,他的思想决定了他所设计出来的软件结构,将自己的灵魂注入到冰冷的代码中,这是一种艺术。然而,不同的人有不同的风格,设计者对于世界的认知方式不同,他们对于同样的需求,可能采用的设计方式也多种多样。

3. 怎么设计我们的象棋程序

3.1. 为象棋程序划分模块

做一个象棋程序,有哪些事情要做呢?

首先,我们要能够初始化一个棋局,把棋盘和棋子绘制出来,点击棋子的时候,能给出它可以走的地方,可以移动这个棋子,也可以吃掉对方的棋子。走完一步,要能够判断是否将军,如果吃掉了对方的将帅,能够判断棋局的终止。这些东西,除了绘图之外,我们都放在棋局模块里,我们有个第一个模块Game。绘图模块的职责比较单一,我们把它放在一个棋盘模块中,这是第二个模块ChessBoard。

天下之事,事事都是棋局,人在局中为名来,为利往,都是棋子。可见,与棋局相对的就是棋子了。按照我们在第二部分提到的思路,棋子应当是要承担一些职责的,那么,哪些事情适合交给棋子来做呢?

我们定义这么一个规则:做一件事,如果有多个参与者,其中某个参与者要付出的代价最大,这一步就由这个参与者来负责做。

我们把走棋的这个过程分解,这里有四个部分:

  • 判断我有没有可能出现在那个位置,比如说,象不能过河,老将和卫士不能出九宫格。
  • 判断目标位置有没有己方棋子,如果有,也过不去。
  • 判断能否直接到达目标位置,比如说,马腿是否被挡着了?象眼是否被塞着了?
  • 移动过去,如果有对方棋子,吃掉它。

从第一步来看,这个过程不依赖于其他任何东西,每个棋子都应当能够牢记自己能去什么地方,不能去什么地方,只要你给它一个棋盘坐标,它自己是可以知道能不能去的。比如象知道自己不能过河,如果你给的坐标就超过了,它可以知道自己不能去。所以,这个职责我们放给棋子。

再看第二步,这个我们怎么判断呢?假设我们是一个士兵,在平原上打仗,我想知道前面山顶有没有人,怎么办?看了很多电影的我们表示,很好办。“总部总部,请侦察对面山顶。”所以,这个过程我们可以看到,检索目标位置不是棋子自身的职责,他只是调用了某个别的东西(己方司令部),得到了结果。

下面是第三步,这里面有可能不需要依赖于其他模块,也可能要依赖,怎么解释呢?比如说卫士,他走路只看距离,如果是他的合法可达位置,并且和当前位置距离的平方为1+1=2,那就可以直接过去,不需要依赖任何外部模块。但是如果是马,要先看距离的平方是不是1+4=5,然后再找马腿的位置,再去看那个位置有没有棋子。所以这种情况下,就要依赖外部模块。

第四步看似很简单,过去的时候发个通知给司令部,我换地方了!但司令部那边要把当前所有人分别在哪都记录着,所以他要做的事情其实比棋子更复杂,所以这一步可以让他做。

于是,我们得出结论,棋子的职责应该是这些:

  • 判断自己是否可能出现在某位置
  • 判断自己能否到达某位置

现在我们就有了Chess模块,并且从它派生出各种棋子,同时,为这些棋子实现一个工厂模块ChessFactory,用于根据参数创建这些棋子。

现在我们来考虑,谁来提供这个查询的服务,承担司令部的这些职责,棋子的位置都保存在棋局中了,所以,很自然地,棋局承担了这个职责。使用。

简单地考虑一下,我们的棋局应当能够:

  • 初始化。初始化方法做的是把棋局恢复成初始状态,每次重新开局之前,我们可以这么做一下。
  • 走棋。走棋是把给出的棋子移动到指定位置,如果目标位置没有别的棋子,只做移动,否则还要把对方杀死。走棋之前有一些判断条件,我们也把它们列出来。
  • 列出某棋子的可达范围。这其实是一个辅助功能,当用户点击某棋子的时候,界面上能够标示出所有该棋子的可到达位置,便于用户选择,当用户选择其中某一个的时候,把棋子移动过去。
  • 判断是否终局。每一步棋走完,我们都需要看一下是否有一方获胜,如果有,本局应当终止。

3.2. 代码结构

根据上述的结论,我们建立了这么4个代码文件,用于存放不同的模块:

  • game,存放棋局相关的功能
  • chessboard,存放绘制棋盘和棋子相关的功能,点击操作也由它负责传递
  • config,存放各种配置信息,比如棋盘大小等等
  • chessman,存放各种棋子的功能和棋子生成器

注意到我在chessman.js里面,定义了多个模块,这其实就是我这个thin框架的核心理念,模块跟文件不一一对应,模块对应于Java中的class文件,而js文件对应于Java中的jar文件,是模块的集合。这么做当然也有弊端,因为无法得知模块是否有冲突,或者存在被覆盖的情况,引用也不是很方面,所以我为此还建立了一套管理和发布机制,专门用来解决这个问题。在小型项目纯手写代码的情况下,直接这样用就可以了。代码细节不一一列出,请读者自行查看。

4. 可能的改进

上面我们实现了一个可以在单机下双人对战的象棋程序,运行得还不错,但是我们想要给它一些增强,应当如何去做呢?

如果我们想要本机开多个棋局,怎么办?

在我们现有结构下,其实很简单,因为我们模块化做得还是挺好的,Game可以作为顶层模型,然后创建出对应的DOM容器,用我们上次写的Bind来扫描一遍,自动创建实例,就可以了。

另外一个很典型的增强是,既然我们都做了单机的对战了,是不是可以搞一个服务端,变成联机的对战呢?当然可以,要做这个,我们需要改动的代码是Game模块,这一步不再适合直接创建了,而是要放在新建棋局的服务端回调里面,棋局的状态也需要在服务端保存一份,然后每次下棋,把走的棋子和坐标放过去,对其他任何模块都没有改动。从这里我们也可以看到如果代码进行了合理的分层,当需要改进的时候,对原代码改动有多么容易。

再有这么一天,我们还要做棋局的撤销怎么办?虽然这个很不好,有损大丈夫的威名,但我们只从实现角度来分析一下。在设计模式中,有一种叫做命令模式,这种模式其实就很适合做undo跟redo,只要把每个事情都封装为步骤,那么,这两种操作就变成了正向和反向的两种步骤了,做起来也就非常容易。

还不满意,要添加人工智能怎么办?抛开人工智能常用的剪枝算法不谈,我们假设已有这么个算法,只需要在一方移动棋子之后,把当前局势传递给这个算法得到下一步即可。

综上所述,做大一点的Web应用,必须先做模块化,把模块按照功能划分,理清它们之间的关系,然后再用合适的框架去管理维护。作者正在编写的thin框架就是试图从模块入手,一步一步添加其他功能,把它做成一个有一定可用性的框架。

本文的Demo地址是:http://xufei.github.io/thin/demo/chess.html