Doris232 发表于 2022-3-26 20:47

中国象棋游戏开发实战(C#)

一、引言

今天开始,我们来山寨一把“中国象棋”这款经典游戏,基于.NET的C#开发。说起中国象棋,很多朋友又该说了,“这有什么难的?一张棋盘、几个棋子而已!”。然后,程序猿之间有句很有名的谚语,Talk is cheap,Show me the code!。当你真正自己动手来实现这款小程序的时候,你会发现并不像你当初想象的那么简单,中国象棋的程序逻辑相当复杂,每一种棋子都有他自己的移动规则和吃子规则,各种棋局又是千变万化,如果没有一个清晰的思路和合理的架构,你的开发将会陷入一场逻辑混乱的噩梦。
闲言少叙、废话少说、言归正传(废话还是不少,呵呵!)
首先让大家看一眼我们的程序运行效果(图1),怎么样?虽然谈不上漂亮,但至少还顺眼吧?呵呵!开个玩笑。其实,本项目的界面十分简单,根本用不上什么高级的知识或技巧,项目的重心在于程序的逻辑部分。


图1
除了中国象棋最基本的一些规则逻辑之外,本项目还扩展了一些实用的辅助功能,如:对方每走一步棋,系统都会给出你下一步可以走的有哪些棋子;当你用鼠标按住某个棋子的时候,会出现一系列绿色的圆圈,以提示你该棋子可以走到哪些位置(图2)。


图2
需要说明的是,我们的这款中国象棋没有考虑人机对战功能及算法的实现,这涉及到人机博弈理论,属于一个比较独立且系统的领域,为了突出重点,不让人机博弈的那些晦涩的算法理论分散大家的注意力,我们只实现象棋的规则逻辑,而没有设计电脑的AI。如果大家对人机博弈的理论及算法感兴趣,可以自行百度一下,网上有很多相关资料,也欢迎大家把自己所领会的人机博弈算法应用到我们的这个项目中来。
本项目目前只实现了单机功能,至于局域网联机对战功能,其实并不复杂,无非就是通过相关的网络协议(TCP、UDP等),发送相关的数据包,并进行解析而已。

二、总体架构设计

要让你从零实现中国象棋游戏,你会有哪些实现思路呢?
有人会说,网上中国象棋程序一抓一大把,干嘛还要自己从零实现呢?好吧,你要这样说,我表示无语;还有人会说,网上有很多现成的游戏引擎,拿过来直接用就行了,只能说你很懂得拿来主义。我们的这个“麻雀系列”项目的目的,就是希望朋友们能够从零实现一些小而精的项目,锻炼自己的动手能力和设计思想,通过提高自己的抽象能力,来锤炼自身的总体架构能力。
好了,回到本项目中来。用什么方法来把棋盘、棋子绘制到电脑屏幕上呢?有人会提到DirectX、WPF、Unity3D等等高大上的技术,还是那句话,我们会忽略掉任何会是我们分心走神的枝节,在本项目中,我们还是采用最最简单、最古老的GDI+绘图技术。有人肯定会表示不屑了,不可否认,DirectX、WPF、Unity3D等确实可以做到更炫的效果、更高的开发及运行效率,但别忘了我们的项目是“麻雀系列”,我们只关注于我们关心的部分。
既然我们采用的面向对象的程序设计思想,我们就用对象的抽象思维来构建我们的程序世界。大家想想,实现中国象棋,大致需要哪些对象来相互协作呢?中国象棋由一张棋盘、32个棋子组成,很简单吧,我们只需要一个棋盘类,一个棋子类。怎么?难以置信?一个程序只有两个类?对的,你没有看错,我们这个项目在业务逻辑上只有这两个类。也许有人在说,你在忽悠我们吧?一个采用面向对象思想开发的项目,只有两个类,开玩笑呢吧!大家别急,待我慢慢道来。
先让大家看一下本项目的基本类图(图3),大家可以看到,程序整体上只有棋盘类(ChessBoard)和棋子类(ChessPiece)两个类,只不过棋子类又有7个子类,从图3中的英文名称可以看出,这7个子类其实就是中国象棋中的7类棋子,即:车、马、象、士、帅、炮、卒。图3的下部还有几个零散的小类,我们在这里先行略过。


图3
本项目基本构架的大致思路是这样的:
棋盘类负责绘制棋盘、初始化棋子等基本操作,并提供交换红黑双方位置、悔棋、维护玩家走棋顺序、索引坐标和像素坐标的转换、根据坐标查询棋子、检测将军及明将、分析下步可走招法、保存历史走棋记录等功能。
棋子类负责绘制自身到棋盘、移动到目标位置、吃子、删除自身、悔棋等职责,同时提供绘制自己下一步可走位置、判断鼠标是否在自己范围内、维护自己的历史移动轨迹、保存自己吃掉的对方棋子的集合等功能。
程序界面采用传统的WinForm,他的职责很简单,就是负责GDI+绘图,以及鼠标、键盘事件的处理。在这里又会有人表示对WinForm不服,说WinForm十分古老、传统、死板,不如WPF等其他更为先进的界面框架。其实,只要我们想,我们可以轻易的把界面框架改为WPF或其他。还是那句话,我们不会被这些枝节所困扰,我们需要的是专注。
经过上面的分析梳理,整个程序的结构是不是很清晰了?好的架构是好的程序的一大部分,如果你的程序架构没有设计好,在以后的具体编码过程中便会遇到各种逻辑陷阱和代码Bug的纠缠,软件将变得难以维护、难以扩展,一旦需求有变动,那你更是会受到非人的折磨。好了,程序的大概骨架已经搭建完毕,接下来我们就可以着手具体实现了。
三、具体功能实现

1、绘制棋盘

千里之行始于足下,玩象棋首先要有棋盘,怎么绘制棋盘呢?我们在主窗体上添加一个PictureBox控件(命名为chessboard),就是图4中左侧的那个木纹控件,在他的上面用GDI+来画棋盘即可。提到GDI+绘图,就离不开OnPaint事件,这个事件对于C#程序员来说不算陌生吧?每当界面需要刷新时,就会调用OnPaint事件,我们需要做的就是在这个事件的处理方法中添加自己的绘图代码就可以了。


图4
大家想一下,绘制棋盘是谁的职责呢?我们知道,在面向对象程序设计思想中,有一条很重要的规则,就是SRP(单一职责原则),一个类尽量只负责自己需要负责的职责,该谁的责任就是谁的责任,一个方法应该归属于他应该归属的类。很显然,棋盘类负责绘制棋盘,这一点我们在前面也说过。
OK,我们建立一个棋盘类,命名为ChessBoard,在该类中添加一个Draw方法,来负责绘制棋盘基本结构。Draw方法的声明如下:
/// <summary>
/// 绘制棋盘
</summary>
/// <param name="g">Graphics对象</param>
public void Draw(Graphics g)
{
}
要绘制棋盘,首先要先把网格线画出来,就是中国象棋棋盘的10行9列的网格(图5)。原理很简单,就是调用Graphics对象的DrawRectangle、DrawLine等方法。


图5
在这里需要先声明几个属性或变量:
BoradWidth:棋盘的宽度;
BoradHeight:高度;
LINE_WEIGHT:线条宽度;
sW:棋盘中每个小格子的宽度;
sH:棋盘中每个小格子的高度;
这里需要着重强调一下LINE_WEIGHT变量,为什么要声明线条宽度呢?因为我们平时用GDI+绘制矩形或直线时,很少会注意到线条的宽度,但在绘制棋盘的过程中,线条是会占用宽度的,只不过默认是1像素。图6表示了GDI+绘制直线时线条是占用宽度的,其中,AB之间的部分是代表线条宽度,红线为AB的中线,GDI+绘制直线时,以中线C为直线的X坐标,根据指定的宽度,在中线两侧渲染直线。


图6
所以,绘制棋盘最外侧矩形边框的代码如下所示,需要考虑到线条宽度和外边框矩形和最外侧的留白部分(图7中的A、B两部分)。
//绘制外边框
g.DrawRectangle(pen,
    LINE_WEIGHT / 2 + sW / 2,
    LINE_WEIGHT / 2 + sH / 2,
    BoradWidth - LINE_WEIGHT - sW,
    BoradHeight - LINE_WEIGHT - sH);
同理,绘制平行横线和平行竖线通过两个循环即可实现,如下。在绘制过程中,同样要考虑到线条宽度的影响。
//绘制纵向线段
for (float i = sW / 2 + LINE_WEIGHT / 2; i < BoradWidth; i += (sW + LINE_WEIGHT))
{
    g.DrawLine(pen,
      i,
      sH / 2 + LINE_WEIGHT / 2,
      i,
      (LINE_WEIGHT + sH) * (BOARD_META_HEIGHT / 2 - 1) + sH / 2 + LINE_WEIGHT / 2);
    g.DrawLine(pen,
      i,
      (LINE_WEIGHT + sH) * (BOARD_META_HEIGHT / 2) + sH / 2 + LINE_WEIGHT / 2,
      i,
      BoradHeight - sH / 2 - LINE_WEIGHT / 2);
}

//绘制横向线段
for (float j = sH / 2 + LINE_WEIGHT / 2; j < BoradHeight; j += (sH + LINE_WEIGHT))
{
    g.DrawLine(pen, sW / 2 + LINE_WEIGHT / 2, j, BoradWidth - sW - 2 - LINE_WEIGHT / 2, j);
}


图7
现在我们已经绘制好了棋盘的格子,至于九宫格、“楚河汉界”字样、炮位、兵位,道理都是一样的,这里就不再赘述了。OK,到现在为止,我们已经把棋盘的整体绘制完毕了。
在介绍如何绘制棋子之前,还要在棋盘类ChessBoard中添加一个byte类型的静态二维数组Matrix,定义如下,该数组用于记录棋盘矩阵各个位置处有没有棋子,有棋子的地方为1,没有则为0。
/// <summary>
/// 棋盘矩阵
/// </summary>
public static byte[,] Matrix = new byte;
2、绘制棋子

棋盘有了,接下来就该画棋子了。我们知道,中国象棋总共有32个棋子、分红黑双方,每个棋子都有不同的名称、不同的位置、不同的颜色、不同的走法、不同的吃子规则。因此,定义棋子类ChessPiece,该类有若干属性及方法。
其中,定义一个Draw方法,原型如下。
/// <summary>
/// 绘制自身
/// </summary>
/// <param name="g">Graphics对象</param>
public void Draw(Graphics g)
{

}
这个Draw方法如何实现呢?首先画一个圆形,贴上一张背景图,代码如下。至于背景图大家可以随便找一个,自己觉得漂亮就行。
g.DrawImage(new Bitmap(global::wChess.Properties.Resources.piece), Left,
    Top,
    ChessBoard.sW,
    ChessBoard.sH);
然后在圆形背景图上面写字,代码如下:
Font font = new Font("楷体", 25);
var fontSize = g.MeasureString(Name, font);
g.DrawString(Name, font, FontBrush, CenterX - fontSize.Width / 2, CenterY - fontSize.Height / 2 + 5);
这样就把棋子画在画布上了,简单吧?细心的朋友估计肯定会发现上面两段代码中有Left、Top、CenterX、CenterY等没见过的东东,这些正是我们接下来要说明的内容。
棋子其实有两种坐标,一个是像素坐标,即该棋子在棋盘画布上的像素位置;另一个是索引坐标,代表该棋子在棋盘里每个小格子组成的二维矩阵中的索引位置。例如图8中,假设棋盘每一个小格子的宽和高都为10像素,同时忽略线条的宽度,则A点的像素坐标为(20,10),它的索引坐标为(2,1)。


图8
棋子类需要有三个代表坐标的属性FixedMetaPosition、OldMetaPosition、FloatMetaPosition,其中FixedMetaPosition表示棋子落地后的位置,OldMetaPosition代表该棋子在走棋之前的上一个位置,FloatMetaPosition代表该棋子移动过程中未放下状态的位置。我们在把棋子绘制到棋盘上的时候,位置信息用的就是FloatMetaPosition。
这些坐标信息都有什么作用呢?棋子不是只需要一个坐标就行了么?从表面上看,棋子确实只需要一个位置信息就可以了,但具体到中国象棋程序中,由于需要给玩家提供悔棋功能,所以每一个棋子都需要记录自己在走棋之前的上一步位置,即OldMetaPosition;为了给玩家提供友好的用户体验,当用鼠标拖动棋子时,该棋子要跟随鼠标移动,在移动状态下的临时位置,就是FloatMetaPosition;而FixedMetaPosition则代表棋子落地后的确定位置。
刚才出现的Left、Top、CenterX、CenterY这几个属性,定义如下,分别代表该棋子所在圆形的外切矩形的左上角坐标和中心点坐标,用于绘制该棋子自身。
private float Left
{
    get
    {
      return (ChessBoard.LINE_WEIGHT + ChessBoard.sW) * FloatMetaPosition.X + ChessBoard.LINE_WEIGHT / 2;
    }
}

private float Top
{
    get
    {
      return (ChessBoard.LINE_WEIGHT + ChessBoard.sH) * FloatMetaPosition.Y + ChessBoard.LINE_WEIGHT / 2;
    }
}

private float CenterX
{
    get
    {
      return Left + ChessBoard.sW / 2;
    }
}

private float CenterY
{
    get
    {
      return Top + ChessBoard.sH / 2;
    }
}
大家想想,棋子除了这些坐标信息,还需要有哪些属性?对了,还要有名称(Name)、是红棋还是黑棋(Team)、该棋子是棋盘上边一方还是下边一方(Side)等属性。其中,Team和Side为枚举类型,定义如下:
/// <summary>
/// 红黑方枚举
/// </summary>
public enum TEAM
{
    RED,
    BLACK
}

/// <summary>
/// 该棋子是棋盘上边的一方,还是下边的一方
/// </summary>
public enum SIDE
{
    UP,
    DOWN
}
有了以上的这些基础,现在我们已经可以在棋盘上绘制出某个棋子了,有成就感吧?
3、移动棋子

到目前为止,我们已经有棋盘、也有棋子了,但这些棋子还不能移动呢,当玩家在某个棋子上按下鼠标并拖动时,该棋子应该跟随鼠标移动;玩家在某一位置松开鼠标,该棋子便落到该位置处,这是中国象棋最基本的走棋方法。
好,我们给棋子类ChessPiece添加一个移动方法,声明如下:
      ///
      /// 移动到指定位置(返回false说明当前还没有轮到己方走棋)
      ///
      /// 目标位置
      ///
      public MOVE_RESULT MoveTo(Point pixelPos)
      {}
这个MoveTo方法的作用就是把当前棋子移动到指定的位置,大家考虑一下该如何实现?基本原理很简单,就是按照中国象棋的走棋规则,先判断当前棋子能不能移动到指定的位置,能的话就把当前棋子的FixedMetaPosition属性设置成鼠标所在的位置。当然了,具体实现过程中,需要考虑的因素还有很多,这里先不管他。
那么怎么判断当前棋子能不能移动到指定位置呢?我们知道,不同的棋子有不同的走棋规则,马走日、象飞田、炮隔山、車打一溜烟等等,这么多不同的规则,该怎么办呢?总不能用类似如下代码来处理吧?这就用到面向对象思想中的封装、继承和多态了,此时正是重构的好时机。
switch(piece.Name)
{
case “车”:
break;
case “马”:
break;
case “炮”:
break;
……
}
4、重构棋子类

由于不同的棋子有不同的走棋规则,我们需要对棋子类进行重构。在上文中我们定义了棋子类ChessPiece,用他来代表所有32个棋子中的任一个棋子,该类的大概结构如下:
public class ChessPiece
    {
      public string Name { set; get; }
      public Point FloatMetaPosition { set; get; }
      public Point FixedMetaPosition { set; get; }
      public Point OldMetaPosition { set; get; }
      public TEAM Team { set; get; }
      public SIDE Side { set; get; }
      private float Left { set; get; }
      private float Top { set; get; }
      private float CenterX { set; get; }
      private float CenterY { set; get; }
      private BrushFontBrush { set; get; }

上面代码是棋子类的共有属性,不同的是各种棋子判断能否移动到指定位置的规则不一样,应该遵守诸如“马走日、象飞田”之类的规则,还有“蹩马脚”、“蹩象腰”等限制。不同的棋子,其下一步可以移动到的位置不同,因此我们为每种棋子添加一个类,并让其继承于ChessPiece类。例如,添加代表“马”的Knights类、代表“車”的Rooks类、代表“炮”的Cannons类等。
在父类ChessPiece中添加一个抽象属性NextSteps,代表当前棋子下一步可以移动的位置集合,定义如下列代码:
      ///
      /// 当前棋子下一步可以移动的位置集合
      ///
      public abstract List NextSteps { get; }
然后定义各个子类,根据自身的走棋规则来实现NextSteps这个抽象属性,这些子类说起来比较简单,只有NextSteps这一个属性,没有其他成员,但这个NextSteps属性在实现上并不简单,下面分别进行说明。
4.1 Rooks(車)类

“車”的走棋规则是只能沿着横向或纵向走直线、且不能越过其他棋子。
为了直观,我们看图9,红“车”在当前状态下,可以移动到的位置为绿色圆圈处,可以分别按照“车”的上方、下方、左侧、右侧四个方向进行实现。


图9
我们以该棋子上方的可移动位置为例进行说明,基本原理是:
假设当前“车”的纵坐标为Y0,寻找该棋子上方第一个Matrix对应元素为1的位置,假设该位置为Y1。
如果Y1处是己方棋子,由于不能吃己方的棋子,所以从该棋子上方第一个位置(Y0-1)、一直到位置(Y1+1),这些位置都是该棋子下一步可以移动到的位置;
如果Y1处是敌方棋子,由于可以吃敌方的棋子,所以从该棋子上方第一个位置(Y0-1)、一直到位置(Y1),这些位置都是该棋子下一步可以移动到的位置;
那么怎么寻找“车”上方第一个Matrix对应元素为1的位置呢?具体实现方法是:在该棋子正上方,从该棋子开始,向上逐个位置判断棋盘矩阵Matrix对应位置的元素是否为1,若为0则说明该位置处没有棋子,继续向上逐个判断,直到到达棋盘最上方;若是1则说明该位置处有棋子,该位置便是我们要寻找的“车”上方第一个Matrix对应元素为1的位置。
具体代码如下:
      //上方的可以移动的位置预览
      private List GetAboveSteps()
      {
            List r = new List();
            var yAbove = FixedMetaPosition.Y;
            do
            {
                if (yAbove - 1 >= 0)
                {
                  if (ChessBoard.Matrix == 1)
                  {
                        var p = ChessBoard.GetPieceFromMetaPos(FixedMetaPosition.X, yAbove - 1);
                        //目标位置只要没有己方棋子
                        if (p.Count > 0 && p.Team != Team)
                        {
                            yAbove--;
                        }
                        break;
                  }
                  yAbove--;
                }
                else
                {
                  break;
                }
            } while (true);
            for (int i = yAbove; i < FixedMetaPosition.Y; i++)
            {
                r.Add(new Point(FixedMetaPosition.X, i));
            }
            return r;
      }
其他三个方向的实现原理是一样的,不再赘述。总之,“车”类Rooks的NextSteps属性的代码如下:
      public override List NextSteps
      {
            get
            {
                List r = new List();
                r.AddRange(GetAboveSteps());
                r.AddRange(GetBelowSteps());
                r.AddRange(GetLeftSteps());
                r.AddRange(GetRightSteps());
                return r;
            }
      }
4.2 Knights(马)类

“马”的走棋规则俗话称为“马走日”,意思是只能从原位置走到“日”字的对角位置;另外,还要考虑“蹩马腿”的情况。


图10
图10中展示了“马”的所有走棋规则,理论上“马”最多应该有8个位置可以移动,即ABCDEFGH八个位置。其中,A、B、C三个位置由于没有任何棋子,所以“马”可以移动到这些位置;D处虽然有棋子,但由于是敌方棋子,所有可以吃掉它;由于被右侧的“炮”(I点)“蹩马腿”,所有“马”不能移动到E、F两个位置;G、H两点由于是己方棋子,不能吃,所以“马”不能移动到这两个位置。
下面我们以F点为例进行说明,其他点同理。
已知F点的索引坐标为(FixedMetaPosition.X + 2, FixedMetaPosition.Y + 1),马腿位置为(FixedMetaPosition.X + 1, FixedMetaPosition.Y)。
首先判断一下F点是否超出棋盘矩形的范围,若超出范围则“马”不能移动到F点;
若没有超出范围,再判断有没有“蹩马腿”,即判断I点在棋盘矩阵对应位置处的元素是否为1,若为1则说明被“蹩马腿”,不能移动到该位置;
若为0,再判断目标位置F处有没有棋子,若没有棋子则可以移动到该位置;
若有棋子,再判断F处是己方棋子还是敌方棋子,若是敌方棋子,由于可以吃掉他,所以可以移动到该位置;
若是己方棋子,则不可移动到该位置。
是不是有点发蒙?呵呵,好好捋捋,代码如下:
if (FixedMetaPosition.X + 2 < ChessBoard.BOARD_META_WIDTH && FixedMetaPosition.Y + 1 < ChessBoard.BOARD_META_HEIGHT)
                {
                  //蹩马腿
                  if (ChessBoard.Matrix == 0)
                  {
                        //如果目标处没有棋子,则可以移动
                        if (ChessBoard.Matrix == 0)
                        {
                            r.Add(new Point(FixedMetaPosition.X + 2, FixedMetaPosition.Y + 1));
                        }
                        else
                        {
                            //虽然目标处有棋子,但该棋子是对方的,也可以移动(此时实际为吃子)
                            var piece = ChessBoard.GetPieceFromMetaPos(FixedMetaPosition.X + 2, FixedMetaPosition.Y + 1);
                            if (piece.Count > 0 && piece.Team != this.Team)
                              r.Add(new Point(FixedMetaPosition.X + 2, FixedMetaPosition.Y + 1));
                        }
                  }
                }
4.3Elephants(象)类

“象”的走棋规则俗称“象飞田”,即只能从原位置走到“田”字格的对角,且不能过河。


图11
图11中展示了“象”的所有走棋规则,理论上“象”最多应该有4个位置可以移动,即ABCD四个位置。A点由于被E点的黑“車”“蹩象腰”,所以不能移动到A位置;B点是敌方的棋子,可以移动到此并吃掉他;C点由于没有任何棋子,所以可以移动到此;D点由于有己方的棋子,所以不能移动到此。
下面以A点为例进行说明。
已知A点的索引坐标为(FixedMetaPosition.X - 2, FixedMetaPosition.Y - 2),象腰位置为(FixedMetaPosition.X - 1, FixedMetaPosition.Y - 1)。代码如下:
                //左上角预览
                if (FixedMetaPosition.X - 2 >= 0 && FixedMetaPosition.Y - 2 >= 0)
                {
                  //没有蹩相腰
                  if (ChessBoard.Matrix == 0)
                  {
                        var ps = ChessBoard.GetPieceFromMetaPos(FixedMetaPosition.X - 2, FixedMetaPosition.Y - 2);
                        //目标位置没有棋子;或者虽有棋子,但该旗子不是己方的
                        if (ps.Count == 0 || ps.Team != Team)
                        {
                            //相不能过河
                            if ((Side == SIDE.UP && FixedMetaPosition.Y - 2 < 5) || (Side == SIDE.DOWN && FixedMetaPosition.Y - 2 >= 5))
                            {
                              r.Add(new Point(FixedMetaPosition.X - 2, FixedMetaPosition.Y - 2));
                            }
                        }
                  }
                }
4.4Mandarins(士)类

“士”位于九宫格内,只能沿着九宫格内的斜线移动,且不能移动到九宫格外面。


图12
图12中展示了“士”的所有走棋规则,理论上“士”最多应该有4个位置可以移动,即ABCD四个位置。AB两点由于没有任何棋子,所以可以移动到此;C点是敌方的棋子,可以移动到此并吃掉他;D点由于有己方的棋子,所以不能移动到此。
下面以A点为例进行说明。已知A点的索引坐标为(FixedMetaPosition.X - 1, FixedMetaPosition.Y - 1),代码如下:
                //左上角可移动位置预览
                if (FixedMetaPosition.X - 1 >= 3)
                {
                  if ((Side == SIDE.UP && FixedMetaPoition.Y - 1 >= 0) || (Side == SIDE.DOWN && FixedMetaPosition.Y - 1 >= 7))
                  {
                        var p = ChessBoard.GetPieceFromMetaPos(FixedMetaPosition.X - 1, FixedMetaPosition.Y - 1);
                        //目标位置若没有棋子,或者有对方棋子
                        if (p.Count == 0 || p.Team != Team)
                        {
                            r.Add(new Point(FixedMetaPosition.X - 1, FixedMetaPosition.Y - 1));
                        }
                  }
                }
4.5 King(帅)类

“帅”也位于九宫格内,可以沿着横向或纵向移动一格,且不能移动到九宫格外面。


图13
图13中展示了“帅”的所有走棋规则,理论上“帅”最多应该有4个位置可以移动,即ABCD四个位置。AB两点由于没有任何棋子,所以可以移动到此;C点是敌方的棋子,可以移动到此并吃掉他;D点由于有己方的棋子,所以不能移动到此。
4.6Cannons(炮)类

“炮”的走棋规则是只能沿着横向或纵向走直线,只有在吃子的情况下才能越过其他一个棋子。


图14
图14中,“炮”在当前状态下,可以移动到的位置为绿色圆圈处,可以分别按照“炮”的上方、下方、左侧、右侧四个方向进行实现。
我们以该棋子上方的可移动位置为例进行说明,基本原理是:
假设当前“炮”的纵坐标为Y0,寻找该棋子上方所有Matrix对应元素为1的位置,假设有n个,并记入集合piecesAbove。
如果n=0,说明“炮”的上方没有任何棋子,这些位置都是可以移动到的位置;
如果n=1,说明上方只有1个棋子,无论这个棋子是己方还是敌方,piecesAbove处以下、“炮”以上的位置都是可以移动到的位置;
若干n>1,说明上方至少有2个棋子,此时,piecesAbove一下、“炮”以上的位置都是可以移动到的位置;然后再看piecesAbove处的棋子是己方还是敌方,如是敌方的棋子则可以移动到此并吃掉。
代码如下:
      //上方可以移动的位置预览
      private List GetAboveSteps()
      {
            List r = new List();
            //当前棋子(“炮”)上方所有位置上存在棋子的位置集合
            List piecesAbove = new List();
            for (int i = FixedMetaPosition.Y; i > 0; i--)
            {
                if (ChessBoard.Matrix == 1)
                  piecesAbove.Add(new Point(FixedMetaPosition.X, i - 1));
            }
            switch (piecesAbove.Count)
            {
                case 0:
                  for (int i = 0; i < FixedMetaPosition.Y; i++)
                  {
                        r.Add(new Point(FixedMetaPosition.X, i));
                  }
                  break;
                case 1:
                  for (int i = piecesAbove.Y + 1; i < FixedMetaPosition.Y; i++)
                  {
                        r.Add(new Point(FixedMetaPosition.X, i));
                  }
                  break;
                default:
                  for (int i = piecesAbove.Y + 1; i < FixedMetaPosition.Y; i++)
                  {
                        r.Add(new Point(FixedMetaPosition.X, i));
                  }
                  var p = ChessBoard.GetPieceFromMetaPos(piecesAbove.X, piecesAbove.Y);
                  if (p.Count > 0 && p.Team != Team)
                  {
                        r.Add(piecesAbove);
                  }
                  break;
            }
            return r;
      }
“炮”类Cannon的NextSteps的整体代码如下:
      public override List NextSteps
      {
            get
            {
                List r = new List();
                r.AddRange(GetAboveSteps());
                r.AddRange(GetBelowSteps());
                r.AddRange(GetLeftSteps());
                r.AddRange(GetRightSteps());
                return r;
            }
      }
4.7Pawns(卒)类

“卒”在过河前只能沿着前进方向一步一格,不能向左、向右、向后移动(图15);过河后则可以向左、向右移动(图16),但还是不能后退。
首先判断当前“卒”有没有过河,“卒”过河后可以向左右移动。怎么判断有没有过河呢?如果当前“卒”是棋盘上面一方的,则若其Y坐标大于等于5,就说明已经过河;如果是棋盘下面一方的,则若其Y坐标小于5,就说明已经过河。


图15


图16
具体代码如下:
public override List NextSteps
      {
            get
            {
                List r = new List();
                //只有在兵卒过河以后,才可以左右移动
                if ((Side == SIDE.UP && FixedMetaPosition.Y >= 5) || (Side == SIDE.DOWN && FixedMetaPosition.Y < 5))
                {
                  //左侧可移动位置预览
                  if (this.FixedMetaPosition.X - 1 >= 0)
                  {
                        var p = ChessBoard.GetPieceFromMetaPos(FixedMetaPosition.X - 1, FixedMetaPosition.Y);
                        //目标位置若没有棋子,或者有对方棋子
                        if (p.Count == 0 || p.Team != Team)
                        {
                            r.Add(new Point(FixedMetaPosition.X - 1, FixedMetaPosition.Y));
                        }
                  }
                  //右侧可移动位置预览
                  if (this.FixedMetaPosition.X + 1 < ChessBoard.BOARD_META_WIDTH)
                  {
                        var p = ChessBoard.GetPieceFromMetaPos(FixedMetaPosition.X + 1, FixedMetaPosition.Y);
                        //目标位置若没有棋子,或者有对方棋子
                        if (p.Count == 0 || p.Team != Team)
                        {
                            r.Add(new Point(FixedMetaPosition.X + 1, FixedMetaPosition.Y));
                        }
                  }
                }
                //前进方向可移动位置预览
                switch (Side)
                {
                  case SIDE.UP:
                        if (this.FixedMetaPosition.Y + 1 < ChessBoard.BOARD_META_HEIGHT)
                     {
                            var p = ChessBoard.GetPieceFromMetaPos(FixedMetaPosition.X, FixedMetaPosition.Y + 1); ;
                            //目标位置若没有棋子,或者有对方棋子
                            if (p.Count == 0 || p.Team != Team)
                            {
                              r.Add(new Point(FixedMetaPosition.X, FixedMetaPosition.Y + 1));
                            }
                        }
                        break;
                  case SIDE.DOWN:
                        if (this.FixedMetaPosition.Y - 1 >= 0)
                        {
                            var p = ChessBoard.GetPieceFromMetaPos(FixedMetaPosition.X, FixedMetaPosition.Y - 1);
                            //目标位置若没有棋子,或者有对方棋子
                            if (p.Count == 0 || p.Team != Team)
                            {
                              r.Add(new Point(FixedMetaPosition.X, FixedMetaPosition.Y - 1));
                            }
                        }
                        break;
                }
                return r;
            }
      }
5、完善棋子类

通过上面一节的叙述,我们把棋子类ChessPiece的7个子类有关NextSteps属性的具体实现完成了。那么我们费这么大劲实现NextSteps抽象属性,目的是什么呢?前边我们在讲ChessPiece类的MoveTo方法的时候说过,要想把棋子移动到某个指定位置,先需要判断当前棋子能不能移动到该指定的位置,能的话就把当前棋子的FixedMetaPosition属性设置成鼠标所在的位置。这就需要给ChessPiece类添加一个方法CanMoveTo,实现如下:
      ///
      /// 判断当前棋子是否能够移动到目标位置
      ///
      /// 目标位置
      ///
      public bool CanMoveTo(Point metaPt)
      {
            return NextSteps.Contains(metaPt);
      }
看到了吧?NextSteps属性的用途原来在这里,只有当前棋子的NextSteps集合中包含目标位置,则说明可以移动到该位置。
好了,下面我们就继续完成ChessPiece的MoveTo方法。大家想想,把当前棋子移动到指定位置,会有几种结果?也就是说,MoveTo方法的返回值有哪些可能?我们用一个枚举类型来说明,如下列代码:
    ///
    /// 走棋的几种结果
    ///
    public enum MOVE_RESULT
    {
      NULL,//默认状态
      ENEMY_TURN,//应该敌方走棋
      CHECK_ENEMY,//对敌方将军
      CHECK_SELF,//己方被将军
      CAN_NOT_MOVE,//不能移动
      MING_JIANG//明将
    }
所以我们把MoveTo方法的返回值类型定义为MOVE_RESULT枚举类型,如下:
public MOVE_RESULT oveTo(Point pixelPos)
{
}
细说MoveTo方法之前,我们需要补充一点,给ChessBoard类添加一个bool类型的静态字段PlayToken,用作玩家的走棋顺序标记,也就是说,用它来标记当前该红黑哪一方走棋,并设定:当PlayToken为true时红方走,false时黑方走。每当一方走一步棋,都要对PlayToken取反,以更新该标记,如下代码:
      ///
      /// 更新走棋属性标记
      ///
      public static void UpdatePlayToken()
      {
            PlayToken = !PlayToken;
      }
在MoveTo方法中,先调用CanMoveTo方法,从而得出能否移动到该指定位置。如果不能移动,则应当把当前棋子恢复原位,并返回MOVE_RESULT.CAN_NOT_MOVE;如果能够移动,我们需要做的有以下几点:
1) 更新玩家走棋标记
这个很简单,就是把PlayToken取反即可,如下:
      ///
      /// 更新走棋属性标记
      ///
      public static void UpdatePlayToken()
      {
            PlayToken = !PlayToken;
      }
2) 吃对方棋子
分如下几个步骤:
a) 判断目标位置处有没有棋子;
b) 如果目标位置没有棋子,则设置eatenFlag为false;这个eatenFlag是什么呢?他是一个bool类型的变量,代表当前棋子最近一次移动有没有吃敌方的棋子,主要是用在实现悔棋操作。
c) 如果目标位置仅有一个棋子,且该棋子是当前棋子自身,此时,也应该设置eatenTag为false,因为自己不能吃自己。
d) 如果目标位置有棋子,但目标位置的棋子是己方棋子,也不能吃。
e) 如果目标位置是敌方棋子,则先保存该棋子,然后吃掉。
f) 设置eatenFlag为ture。
3) 当放下当前棋子时,更新棋盘矩阵对应位置的值
      ///
      /// 当放下当前棋子时,更新棋盘矩阵对应位置的值
      ///
      public void UpdateMatrix()
      {
            ChessBoard.Matrix = 1;
            if (FloatMetaPosition != FixedMetaPosition)
                ChessBoard.Matrix = 0;
      }
4) 使该棋子的历史轨迹进栈
我们需要给棋子类添加一个属性HistorySteps,这是一个堆栈类型,用于记录该棋子的历史位置轨迹,定义如下。
      ///
      /// 用堆栈记录下该棋子的历史位置轨迹
      ///
      public Stack HistorySteps { set; get; }
有了这个属性后,通过压栈,来保存最近一次的历史位置,即:
HistorySteps.Push(FixedMetaPosition);
5) 保存旧位置
OldMetaPosition = FixedMetaPosition;
6) 当前棋子移动到新位置后,更新FixedMetaPosition
FixedMetaPosition = FloatMetaPosition;
7) 保存当前棋局中所有走过的棋子记录
首先我们需要在棋盘类ChessBoard
      ///
      /// 当前棋局中所有走过的棋子记录
      ///
      public static Stack HistoryStack
      {
            get
            {
                return _HistoryStack;
            }
            set
            {
                _HistoryStack = value;
            }
      }
8) 判断是否将军
9) 判断是否明将
6、添加鼠标动作

到目前为止,我们只是在算法层面实现了不同棋子的走棋规则,但还没有实现用鼠标拖动棋子的功能呢!也就是说,我们目前只是完成了走棋的逻辑,但还没有实现界面操作。正所谓“磨刀不误砍柴工”,有了上面这些铺垫,完成界面逻辑便是水到渠成的事情了。
在本课程刚开始的时候,我们说过,之所以用GDI+来画棋子,而不用Button或PictureBox控件来作为棋子。这样做的好处就是界面渲染效率高、不会发生卡顿现象;缺点就是鼠标事件在实现上不太直接,因为控件都有现成的鼠标事件,而GDI+绘图则需要用间接的方法来实现鼠标事件了。
由于棋子是用GDI+画出来的,那么怎么实现鼠标按下、拖动、松开的事件呢?基本原理就是,通过判断当前鼠标的位置是否位于某个棋子的内部,是的话,此时如果按下鼠标拖动,我们的程序就应该响应该事件。
那么怎么判断当前鼠标的位置是否位于某个棋子的内部呢?方法也很简单,就是计算棋子中心点和当前鼠标所在位置处之间的距离,如果该距离小于或等于棋子的半径,则说明鼠标位于棋子的内部,反之则在外部。
好了,有了上的基础,在程序主界面中,当在某个棋子内部按下鼠标时,应该显示该棋子可以移动到的所有位置的预览,也就是类似图9中的那些绿色圆圈。另外,当鼠标在某个棋子内部按下的时候,需要在该棋子外围画一个红色的圆圈,以表明这个棋子是玩家选中的棋子。
当鼠标按下某个棋子并拖动的时候,该棋子应该跟随鼠标移动;鼠标松开后,在符合走棋规则的前提下,该棋子应该落到当前鼠标所在的位置。如果该位置处有敌方棋子,则吃掉它。有了以前的准备,实现这些事件就很简单了,只需要填充MouseDown、MouseMove、MouseUp这些事件响应就可以了。
7、走棋顺序控制(PlayToken)

我们知道,中国象棋游戏中,应该是红黑双方轮流走棋,即你走一步、我走一步,不能一方连续走棋。因此,我们在棋盘类ChessBoard中定义一个bool类型的属性PlayToken,当应该红方走棋时,PlayToken为true;黑方走棋时,PlayToken为false。每当有一方走棋后,都要对PlayToken的值取反,以动态跟踪当前走棋顺序。
8、悔棋(RollBack)

当玩家走棋后后悔了,想退回到走棋前的状态,我们的程序提供悔棋功能。
假设红方棋子“炮”从A点移动到了B点,吃掉了原来在B点的黑方的“马”。此时,要想实现悔棋功能,大致分两步走:一是把红方“炮”的位置从B点改变为原来的A点,二是恢复B点原来被吃掉的黑方的“马”。当然,棋盘矩阵Matrix对应位置处的元素也要跟着更新。
要想实现悔棋,这就需要用一个堆栈变量来记录某个棋子走过的位置、吃掉的棋子等信息。当需要悔棋时,只需依次弹出堆栈栈顶的元素,即可得到该棋子走棋前的位置、吃子等信息,从而进行恢复。
9、将军检测(CheckCheck、IsMovingSideChecked)

10、着法提示(CheckAvailableSteps)

这个功能是我们项目的一个特色功能,就是当敌方走棋后,系统会自动提示己方下一步有哪些着法可以用,在主界面右侧以列表的形式呈现给玩家。
这个功能还有另外一个作用,就是检测死局,即如果敌方走棋后,发现己方已经无路可走,则说明当前已是死局,己方输。
11、明将检测(CheckMingJiang)

12、交换双方位置(ExchangeSide)

13、重新开始棋局(ResetGame)

Ylisar 发表于 2022-3-26 20:57

写的很清楚明白,对于面相对象又有了新的认识,谢谢

xiangtingsl 发表于 2022-3-26 20:59

您的鼓励是我创作的动力[酷]

mypro334 发表于 2022-3-26 21:06

很好,理解了,有启发

Zephus 发表于 2022-3-26 21:16

谢谢
页: [1]
查看完整版本: 中国象棋游戏开发实战(C#)