建一间茅草屋和建一幢大楼有什么本质区别吗?可以说有,也可以说没有。

对于一个资深的建筑师,从一个小小的茅草屋的建造上也能看到各种工程学,也能看到各种设计。只不过应用场景比较简单,许多环节简化了。所以对他来说,两者没有本质区别,只是工程大小而已。

而对于一个没建过大楼只盖过茅草屋的人来说,他看到的、掌握的知识都受限,他一般是不懂得如何建造一幢大楼的。那么对于这样的人来说,两者有着本质的区别了。

回顾我们前面两篇连载,Canvas绘图基础、动画基础都已经讲述完毕,我们甚至也像模像样地画出了棋盘、棋子。让我们继续我们的事业吧。

我们本期目标是实现完整的行棋逻辑。因暂无AI,我们轮流拖拽两方棋子来行棋,每步棋要由程序判断是否合法。如果合法,则落子到目标位置,否则棋子返回原处。这中间使用前面讲过的动画效果。此外如果一个子被吃掉,动画过程结束后将其移出棋盘。

考虑一个问题,我们如何处理这么多棋子及其拖拽、动画效果?建一个数组,当发生鼠标事件时一个个找它们,然后再做逻辑判断?本质上确实是要这么干。不过我似乎已经隐约看到一坨一坨的难以维护的代码。“在我有限的记忆里,记不起太多事”,哪天我忘了这一坨代码,将怎么维护它呢?

让我们做做“设计”吧,把问题简化一下。

首先,可以想象我们需要不少坐标运算。比如将鼠标位置转换成我们Canvas上的相对位置,将Canvas上的相对位置转换成9X10的棋盘上的位置等。那么,动手将它们封装成函数吧。此外因为整个Canvas对象只有一个鼠标事件,我们要判断这个事件是发生在哪个棋子上,这时要用到坐标点是否在某形状区域内的判断等。还有当我们移动棋子时,会需要形状移动位置的运算。所以,让我们封装出 Point和Rect类吧,给它们实现move, moveTo等方法,还有isOverlap判断形状间是否重叠等。

我们的Canvas图形元素是一层一层的,它们的重绘要有顺序,鼠标事件的判断也要有顺序。棋子的运算里可能会需要棋盘的相关数据,每个棋子还有不同的逻辑。这实在想着就觉得乱。好吧,我们OOP一下吧。用Widget类封装基本Canvas元素,然后继承出棋盘、棋子等,棋盘做为所有棋子的父对象(不是继承哦)。可以把每个Widget实例想象成一个窗口,那么Widget基类可以帮我们搞定鼠标事件的分发和重绘操作的实现,因为每个Widget类要保存父对象和所有子对象,当它收到鼠标事件时,会遍历子对象并判断是否需要传给子对象。重绘操作与此类似,通过遍历子对象实现。

如果你做过MFC窗口编程或类似的东西,你会发现,好眼熟好耳熟啊!没错,我们就是要模拟实现一个小的MFC窗口对象。

有了继承,有了Widget父子关系,我们就可以开始设计具体的实现了。

  • Widget只有鼠标事件分发功能,并没有在初始化时绑定事件,因为只有一个Widget需要这一操作,其它的接受通知即可。
  • 再设一个RootWidget类,实现事件绑定。然后,棋盘做为我们的“父窗口”,继承自RootWidget类。
  • Board类除绘制棋盘外,还实现一些棋子管理操作,因为所有棋子都是它的“子窗口”。
  • Chess类继承自Widget类,实现棋子的绘制、运动等操作,但没有具体的行棋逻辑。
  • Hourse等类是具体的棋子类,继承自Chess类,仅需实现自己的行棋逻辑即可。

有了条理,我们的工程看起来就轻松多了,要修改什么东西也比较方便。附上我简单制作的UML类图:

Canvas中国象棋UML类图

Canvas中国象棋UML类图

图中我们的实现逻辑已经比较清晰了。下面来看一些关键点。

在Widget类中,onMouseDown的实现代码如下:

        this.eachChild(function(el) {
            if(el.hitTest(point)) {
                el.onMouseDown(point);
                return true;
            } else {
                return false;
            }
        }, true);

eachChild是自行实现的一个遍历children数组中所有子对象的小工具,简单来说就是一个简化的循环。对于每个子对象,我们首先做hitTest。什么是hitTest?就是判断鼠标事件发生的位置是否属于该对象所处的位置。再简单点说,就是看看用户到底点的是谁。hitTest的实现:

    return this.offsetRect.isPointIn(point);

offsetRect是每个Widget用来保存自己所占区域的位置及大小信息的Rect对象。这里需要指出的是,我们并没有严格区分圆形的棋子,只是计算它所占的矩形区域。Rect类里isPointIn用来判断一个点是否处于自己的区域范围内。其实现不过是一些坐标判断而已:

	return this.left <= point.x
            && this.right > point.x
            && this.top <= point.y
            && this.bottom > point.y;

这样,看似混乱的问题就被简洁地解决了。这要感谢OOP带给我们的益处。鼠标的其它事件处理与此类似,不再赘述。下面看看重绘操作的流程:

  1. 调用show或redraw可以触发绘图操作;
  2. redraw实现的逻辑是先调自己的onPaint,再遍历、调用子类的redraw;
  3. onPaint是实际绘图的方法,子类覆盖它后只需操心自己这层的绘图。

为什么要有redraw和onPaint之分呢?目的是让onPaint除了绘图外不需关心其它事。子元素的绘制会由redraw来完成,通常子类不需要覆盖redraw方法,只需要实现自己的onPaint即可。可以看出,先绘自己再让子对象绘制,得到的结果就是子对象会在自己的上面(就像HTML里的默认Z索引关系一样)。而子对象之间的Z索引关系(鼠标事件的优先顺序也一样)取决于数组顺序。实际上我们还提供了一个moveChildToTop,该方法实际上就是改变子对象在数组中的顺序。

现在让我们看看Chess,它是所有棋子的基类,封装了棋子的除行棋逻辑外的其它基本操作,包括拖拽等。

有了我们前面的设计,现在拖拽操作实现起来就跟HTML层的拖拽一样简单:鼠标按下标记拖拽开始,移动时改变位置(只需要修改offsetRect并调用棋盘的redraw),鼠标松开时结束。

在移动过程中我们会对有效的落子位置进行视觉提示,在有效落子位置上绘制一个半透明的圆。先看下onMouseMove的实现吧:

	if(this.isDragging){
		var x = point.x - layout.cell / 2, y = point.y - layout.cell / 2;
		this.offsetRect.moveTo(x, y);
		var pos = this.point2chessPos(x, y);
		if(this.isTargetValid(pos))
			this.targetPos = pos;
		else
			this.targetPos = null;
		this.parent.redraw();
	}

如果发现当前拖拽位置对应一个有效的落子位置,就把这个位置赋值给this.targetPos。在重绘时会根据此属性绘制对应的指示信息。

对有效位置的判断我们使用了isTargetValid方法。在Chess类中此方法仅判断了棋子是否在棋盘的有效位置上并且不在已方棋子上方。嗯,很基本的逻辑。剩下的就交给具体的棋子类了。我们以“马”为例来看一下实现一个棋子逻辑有多简单:

	isTargetValid: function(pos){
		if(!this._super(pos))
			return false;
		// “马”行棋逻辑判断
		var dx = pos.x - this.pos.x, dy = pos.y - this.pos.y;
		if(dx == 0 || dy == 0 || Math.abs(dx) + Math.abs(dy) != 3)
			return false;
		var targetChess = this.parent.findChess(pos);
		// 是否有绊马腿
		var blockPos = new Point(this.pos.x, this.pos.y);
		if(Math.abs(dx) == 2)
			blockPos.x += dx / 2;
		else
			blockPos.y += dy / 2;
		return this.parent.findChess(blockPos) == null;
	}

它首先调了基类方法做基本验证,然后就是“马”本身所需的验证。验证分两部分,先是判断运动路径是否形成一个“日”字对角线,然后判断有没有绊马腿。

对,就这么简单。我们的中国象棋终于可以下了。虽然现在没有AI,但用来双人对弈或研究棋艺,它也算具备一定实用性了。如果有机会,我们可以把它进一步加强。本篇的总结:做事之前用心去设计一下,事情将变得出乎意料的简单。

附件:基于Canvas的中国象棋

本文作者:admin 转载请注明来自:携程设计委员会