程序设计 之 C#实现《拼图游戏》 (下) 原理篇

前言:在 http://www.cnblogs.com/labixiaohei/p/6698887.html 程序设计 之 C#实现《拼图游戏》(上),上传了各模块代码,而在本文中将详细剖析原理,使读者更容易理解并学习,程序有诸多问题,欢迎指出,共同学习成长!

正文:

拼图是一个非常经典的游戏,基本每个人都知道他的玩法,他的开始,运行,结束。那么,当我们想要做拼图的时候如何入手呢?答案是:从现实出发,去描述需求(尽量描述为文档),当我们拥有了全面的需求,就能够提供可靠的策略,从而在代码中实现,最终成为作品!

(一)需求:(这个需求书写较为潦草,为广大小白定制,按照最最最普通人的思维来,按照参与游戏的流程来)

   1.图片:我们玩拼图 最起码有个图

   2.切割:拼图不是一个图,我们需要把一个整图它切割成N*N的小图

   3.打乱:把这N*N的小图打乱顺序,但是要保证通过游戏规则行走能还原回来

   4.判断:判拼图成功

   5.交互:我们使用哪一种交互方式,这里我选择鼠标点击

     6.展示原图片完整的缩略图

    以上为基本功能,以下为扩展功能

     7.记录步数:记录完成需要多少步

     8.更换图片:一个图片玩久了我们是不是可以换一换啊 哈哈

     9.选择难度:太简单?不要!3*3搞定了有5*5,5*5搞定了有9*9,舍友挑战最高难度 3000多步,心疼我的鼠标TAT

(二)分析:

   有了需求,我们就可以分析如何去实现它(把现实需求映射在计算机中),其中包括:

           1.开发平台:这里选择C#语言

    1.存储:其中包括我们要存什么?我们用什么结构存?我们反观需求,会发现,有一些需要存储的资源

      图片:使用 Image 对象存储

      单元(原图片切割后的子图像集合):自定义结构体 struct Node ,其中包括Image对象用来存储单元小图片,和用整形存储的编号(切割以后,每个小单元都弄个编号,利于检验游戏是否完成)。

      各单元(原图片切割后的子图像集合):使用二维数组(像拼图,五子棋,消消乐,连连看,俄罗斯方块等平面点阵游戏都可以用他来存储,为什么?因为长得像嘛!)来存储

      难度:使用自定义的枚举类型(简单and普通and困难)存储

      步数:整形变量 int Num存储 

  有了存储,我们就可以去思考模块的划分(正确的逻辑划分已于扩展,也可以使通信变得更加清晰)并搭建,并实现各模块涉及到的具体算法

      首先程序的模块分为四个:

  逻辑型:

    1.拼图类:用于描述拼图

    2.配置类:存储配置变量

  交互型:

    3.游戏菜单窗口:进行菜单选项

    4.游戏运行窗口:游戏的主要界面

    

 

  1.通过游戏菜单可以操纵配置,如难度或图片。

  2.运行窗口可以访问并获得游戏配置,并利用其对应构造拼图对象。

  3.用户通过运行窗口进行交互,间接使拼图对象调用移动方法,获得图案方法

  看代码的同学,我觉得最有问题的地方,不合理的地方就是把 难度的枚举类型写在了拼图类中,应该写在配置类中,或单独成类,读者们自行更改

 public enum Diff         //游戏难度
        {
            simple,//简单
            ordinary,//普通
            difficulty//困难
        }

 

我们可以认为,配置类就像数据存储,而拼图类呢作为逻辑处理,菜单和运行窗口作为表现用于交互,我承认这种设计不是很合理,但是在问题规模不够大的时候,过分的考虑设计,会不会使程序变得臃肿?我想一定是有一个度,具体是多少,我不得而知,但我感觉,针对这个程序,实现就好,沉迷设计(套路型),有时得不偿失。(个人不成熟的小观点)

(三)代码实现:

  说明:本块重点描述 Puzzle(拼图)类与游戏运行类的具体实现及实体通讯:

拼图的构造方法:

    1.赋值 :

public Puzzle(Image Img,int Width, Diff GameDif)// 拼图的图片,宽度(解释:正方形的边长,单位是像素,名字有歧义,抱歉),游戏的难度

 

 游戏的难度决定你分割的程度,分割的程度,决定你存储的数组的大小,如简单对应3行3列,普通对应5行5列,困难对应9行9列

 

 switch(this._gameDif)
            {
                case Diff.simple:    //简单则单元格数组保存为3*3的二维数组
                    this.N = 3;
                    node=new Node[3,3];
                    break;
                case Diff.ordinary:    //一般则为5*5
                    this.N = 5;
                    node = new Node[5, 5];
                    break;
                case Diff.difficulty:  //困难则为9*9
                    this.N = 9;
                    node = new Node[9, 9];
                    break;
            }

2.分割图片

            //分割图片形成各单元保存在数组中
            int Count = 0;
            for (int x = 0; x < this.N; x++)
            {
                for (int y = 0; y < this.N; y++)
                {

                    node[x, y].Img = CaptureImage(this._img, this.Width / this.N, this.Width / this.N, x * (this.Width / this.N), y * (this.Width / this.N));
                    node[x, y].Num = Count;
                    Count++;
                }
            }

 其实对单元数组进行赋值的过程,使用双层for循环对二维数组进行遍历操作,然后按序赋值编号node[x,y].Num;

 

然后对node[x,y].Img,也就是单元的小图片赋值,赋值的方法是,C#的图像的类库,写一个截图方法,使用这个方法,将大图中对应对位置的对应大小的小图截取下来,并保存在node[x,y].Img中;

width/N是什么?是边长除以行数,也就是间隔嘛,间隔也就是每个单元的边长嘛!然后起始坐标(X,Y)起始就是在说,隔了几个单元后,我的位置,

即 :(x,y)=(单元边长*距离起始X轴相距单元数,单元边长*距离起始点Y轴相距单元数);

关于此类问题,希望读者能够多画画图,然后自然就明白了;

 public  Image CaptureImage(Image fromImage, int width, int height, int spaceX, int spaceY)

主要逻辑:利用DrawImage方法:

            //创建新图位图   
            Bitmap bitmap = new Bitmap(width, height);
            //创建作图区域   
            Graphics graphic = Graphics.FromImage(bitmap);
            //截取原图相应区域写入作图区   
            graphic.DrawImage(fromImage, 0, 0, new Rectangle(x, y, width, height), GraphicsUnit.Pixel);
            //从作图区生成新图   
            Image saveImage = Image.FromHbitmap(bitmap.GetHbitmap());

分割了以后,我们要做一个特殊处理,因为我们知道,总有那么一个位置是白的吧?我们默认为最后一个位置,即node[N-1,N-1];

就是写改成了个白色的图片,然后四周的边线都给画成红色,已于被人发现,显著一些,之前的其他单元我也画了边线,但是是白色,也是为了在拼图的观赏性上得到区分。该代码不做介绍。

 

3.打乱图片:

其实就是将二维数组打乱,我们可以采取一些排序打乱方法但是请注意!不是每一种打乱都能够复原的!

那么如何做到可行呢?方法理解起来很简单,就是让我们的电脑在开局之前,将完整的有序的单元按照规则中提供的行走方式进行无规则,大次数的行走!也就是说这种方法一定能走回去!

先理解,具体打乱方法,在后面讲解。

 

移动方法(Move):

拼图游戏中方格的移动,其实就是两个相邻单元的交换,而这两个单元中,必定存在一个白色单元(即上面提到的node[N-1,N-1]单元,他的编号为N*N-1,建议自己动笔算一算)

所以我们的判断条件是,如果移动一个方块,他的上下左右四个方向中,一旦有一个相邻的是白色单元,即N*N-1号单元,则与其交换。这是基本逻辑,但不包括约束条件,当我们的数组达到边界的时候,我们就不能对越界数据进行访问,如当单元为node[0,0]时,你就不能对他上面和右面的数据进行访问,因为Node[-1,0] Node[0,-1]都会越界,发生异常

移动成功,返回TRUE 

移动失败,返回FALSE

/// <summary>
        /// 移动坐标(x,y)拼图单元
        /// </summary>
        /// <param name="x">拼图单元x坐标</param>
        /// <param name="y">拼图单元y坐标</param>
        public bool Move(int x,int y)
        {
            //MessageBox.Show(" " + node[2, 2].Num);
            if (x + 1 != N && node[x + 1, y].Num ==  N * N - 1)
            {
                Swap(new Point(x + 1, y), new Point(x, y));
                return true;
            }
            if (y + 1 != N && node[x, y + 1].Num ==  N * N - 1)
            {
                Swap(new Point(x, y + 1), new Point(x, y));
                return true;
            }                
            if (x - 1 != -1 && node[x - 1, y].Num == N * N - 1)
            {
                Swap(new Point(x - 1, y), new Point(x, y));
                return true;
            }   
            if (y - 1 != -1 && node[x, y - 1].Num == N * N - 1)
            {
                Swap(new Point(x, y - 1), new Point(x, y));
                return true;
            }
            return false;
                
        }

  交换方法(Swap):交换数组中两个元素的位置,该方法不应该被类外访问,顾设置为private私有权限

        //交换两个单元格
        private  void Swap(Point a, Point b)
        {
            Node temp = new Node();
            temp = this.node[a.X, a.Y];
            this.node[a.X, a.Y] = this.node[b.X, b.Y];
            this.node[b.X, b.Y] = temp;
        }

 

打乱方法:

 前面提到,其实就是让电脑帮着乱走一通,说白了就是大量的调用Move(int X,int y)方法,也就是对空白位置的上下左右四个相邻的方块中随机抽取一个,并把它的坐标传递给Move使其进行移动,同样要进行越界考虑,这样的操作大量重复!代码自己看吧 ,利用随机数。

   /// <summary>
        /// 打乱拼图
        /// </summary>
        public void Upset()
        {
            int sum = 100000;
            if (this._gameDif == Diff.simple) sum = 10000;
            //if (this._gameDif == Diff.ordinary) sum = 100000;
            Random ran = new Random();
            for (int i = 0, x = N - 1, y = N - 1; i < sum; i++)
            {
                long tick = DateTime.Now.Ticks;
                ran = new Random((int)(tick & 0xffffffffL) | (int)(tick >> 32)|ran.Next());
                switch (ran.Next(0, 4))
                {
                    case 0:
                        if (x + 1 != N)
                        {
                            Move(x + 1, y);
                            x = x + 1;
                        }
                            
                        break;
                    case 1:
                        if (y + 1 != N)
                        {
                            Move(x, y + 1);
                            y = y + 1;
                        } 
                        break;
                    case 2:
                        if (x - 1 != -1)
                        {
                            Move(x - 1, y);
                            x = x - 1;
                        }      
                        break;
                    case 3:
                        if (y - 1 != -1)
                        {
                            Move(x, y - 1);
                            y = y - 1;
                        }
                        break;
                }

            }
        }

返回图片的方法:

当时怎么起了个这样的鬼名字。。。DisPlay。。。

这个方法与分割方法刚好相背,这个方法其实就是遍历数组,并将其进行组合,组合的方法很简单,就是将他们一个一个的按位置画在一张与原图相等大小的空白图纸上!最后提交图纸,也就是return一个Image;

        public Image Display()
        {
            Bitmap bitmap = new Bitmap(this.Width, this.Width);
            //创建作图区域   
            Graphics newGra = Graphics.FromImage(bitmap);
            for (int x = 0; x < this.N; x++)
                for (int y = 0; y < this.N; y++)
                    newGra.DrawImage(node[x, y].Img, new Point(x * this.Width / this.N, y * this.Width / this.N));
            return bitmap;
        }

同样利用的是DrawImage方法,知道如何分割,这个应该很容易理解,自己算一算,在纸上比划比划就明白了;

 

判断方法:

该方法很容易理解,就是按序按序!遍历所有单元,如果他们的结果中有一个单元的编号

node[x, y].Num 不等于遍历的序号,那么说明,该单元不在原有位置上,即整个图片还没有完成,我们就可以直接返回假值false
如果所有遍历结果都正确,我们可认为,图片已复原,此时返回真值true

public bool Judge() { int count=0; for (int x = 0; x < this.N; x++) { for (int y = 0; y < this.N; y++) { if (this.node[x, y].Num != count) return false; count++; } } return true; }

 

 

游戏运行窗口:即游戏玩耍时用于交互的窗口

这里只讲一个方法:即当接受用户鼠标点击事件时我们应该怎么处理并作出什么样反应

其实说白了就这句难懂:

puzzle.Move(e.X / (puzzle.Width / puzzle.N), e.Y / (puzzle.Width / puzzle.N))

调用了移动方法,移动方块

横坐标为:e.X / (puzzle.Width / puzzle.N)
纵坐标为:e.Y / (puzzle.Width / puzzle.N)

我们编程中的整数除法和数学里的除法是不一样的!比如10/4数学上等于2余2或者2.5,计算机里直接就是等于2了,只取整数部分


行数=行坐标 / 方块边长

列数=列坐标 / 方块边长

我们看P1,P2这两点

P1:40/30*30=1
P2:50/30*30=1

我们会发现同在一个单元格中,无论点击哪个位置,通过这个算法都能转化为
同一个坐标。

(e.x,e.y)为鼠标点击事件点击坐标

 private void pictureBox1_MouseClick(object sender, MouseEventArgs e)
        {
            if (puzzle.Move(e.X / (puzzle.Width / puzzle.N), e.Y / (puzzle.Width / puzzle.N)))
            {
                Num++;
                pictureBox1.Image = puzzle.Display();
                if (puzzle.Judge())
                { 
                    if (MessageBox.Show("恭喜过关", "是否重新玩一把", MessageBoxButtons.OKCancel) == DialogResult.OK)
                    {
                        Num = 0;
                        puzzle.Upset();
                        pictureBox1.Image = puzzle.Display();
                        
                    }
                    else
                    {
                        Num = 0;
                        closefather();
                        this.Close();
                    }

                } 

            }
            NumLabel.Text = Num.ToString();
        }

 

 好,那么大体的逻辑,程序中最需要思考的算法已经讲完了,还有不太懂的地方,欢迎交流~么么哒~

加了点小功能 音乐历史成绩 

 

posted @ 2017-04-15 12:00  蜡笔小黑  阅读(7143)  评论(10编辑  收藏  举报