• 以前我自己设计的“俄罗斯方块”,觉得挺有意思,今天贴出来


    “俄罗斯方块”游戏设计
              电子科技大学软件学院03级02班 周银辉
                                     转载请注明出处


    说明:
    这是一次尝试,一个比较成功的设计,其精彩的算法与漂亮的程序结构足以让人兴奋了。
    这有别于常规的俄罗斯方块算法,如果你需要常规的实现方法,可以通过这个e_mail:yinhui_zhou@yahoo.com.cn索取。


    平台说明:
    开发平台    ms.net 2005
    开发语言    C# 2.0


    特殊名词说明:
    3.1,象素坐标(Pixel Coordinate):以显示区域所在控件的Client Rectangle的左上角为坐标原点,一个象素点为单位1的坐标

    3.2,网格坐标(Gird Coordinate):如果我们将显示区域分成m×n 的网格,那么其中某一个网格所在(列,行)组成的坐标,我们称之为网格坐标,在程序中网格坐标相关项以Gird或g或G开头

    3.3,块(Block):一个小格子(比如游戏中的方块图形是由小格子组成的)称为一个块,将由块来组成游戏图形

    3.4,形状(Shape):游戏中的由四个小格子组成的图形称为形状

    3.5,基础点(Base Point):用于指示目前形状所在位置的一个(列,行)坐标点,以网格坐标为坐标。后面将详细说明其来历。

    3.6,动态方块(Dynamic Block):用于构成运动的形状的Block,共4个

    设计思想:
    4.1,屏幕的组成:
         0 ⒈⒉⒊⒋⒌⒍⒎⒏⒐
      0  □□□□□□□□□□
      1  □□□□□□□□□□
      2  □□□□□□□□□□ 
    --------------                     
      3  □□□□□□□□□□
      4  □□□□□□□□□□
      5  □□□□□□□□□□
      6  □□□□□□□□□□
      7  □□□□□□□□□□
      8  □□□□□□□□□□
      9  □□□□□□□□□□
     10  □□□□□□□□□□
     11  □□□□□□□□□□
     12  □□□□□□□□□□
     13  □□□□□□□□□□
     14  □□□□□□□□□□
     15  □□□□□□□□□□
     16  □□□□□□□□□□
     17  □□□□□□□□□□
     18  □□□□□□□□□□
     19  □□□□□□□□□□
     20  □□□□□□□□□□
     21  □□□□□□□□□□
     22  □□□□□□□□□□
    --------------
     23  ■■■■■■■■■■
    屏幕由23行10列的网格组成;其中0~2行:初始的形状将在这里形成然后下落,这三行用户不可见;3~22行:游戏显示区域;23行,其标记已到屏幕底部。

    4.2,形状的组成:
      每一种形状都是由四个方块组成,比如■■■■由四个方块横向排列而成

    4.3,形状的统一:
      ■■■■等共19种形状(旋转前后的形状归为不同的形状),虽然在玩游戏时我们会去将各种不同的形状命不同的命(比如“条子”,“方块”等),但在设计游戏是它们却是统一的,它们都是“形状”。这一点是游戏成功的基础。
         为了使各种不同的形状达到统一的设计,我设计了如下解决方案:将形状始终放在4×4的格子中,以该4×4格子的第一个格子为“基础点”,只要给出组成形状的四个块相对于该基础点的相对坐标,那么在基础点网格坐标的基础上就能求出各块的网格坐标。
    ★□□□   ★为基础点,形状各块的相对坐标是相对于这个基础点的
    □□□□
    □□□□
    □□□□
    那么■■■■在其中就如图:其四个方块相对于基础点的网格坐标就为(0,2)(1,2)(2,2)(3,2)
    □□□□
    □□□□
    ■■■■  02122232
    □□□□ 
    假设基础点的网格坐标是(gX, gY),那么此形状的坐标就为(gX+0,gY+2), (gX+1,gY+2), (gX+3,gY+2), (gX+3,gY+2)
    我们将用一个int[8]记录下这四个相对坐标值(呵呵,用byte[8]就可以了哈)
    同理:
    □□□□
    □□□□
    ■■□□
    ■■□□  02120313
    这样,我们只要知道某个形状的相对坐标值数组,就可以轻松地求出它的各方块的排列方式,也就是其形状(样子)

    4.4,移动与旋转的统一
       从上面我们可以看出形状的移动可以这样来实现: 移动基础点的网格坐标,然后组成形状的四个方块按照其与基础点坐标的相对值而改变网格坐标,则表现为移动。
       旋转与移动的原理一样:设旋转前的形状为A,旋转后的形状为B,组成形状A的四个方块按照B(而不是按照A)的相对于基础点坐标的相对值而改变网格坐标,则表现为旋转。
    比如,
    □□□□
    □□□□
    ■■■■  02122232
    □□□□ 
    移动: 设其基础点网格坐标为(gX,gY),其各方块当前坐标(gX+0,gY+2), (gX+1,gY+2), (gX+3,gY+2), (gX+3,gY+2)。如果其向左移动一格,那么它的基础了坐标gX-=1; gY=gY; 其各方块移动后坐标 (gX+0,gY+2), (gX+1,gY+2), (gX+3,gY+2), (gX+3,gY+2)。
    旋转:设其基础点网格坐标为(gX,gY),其各方块当前坐标(gX+0,gY+2), (gX+1,gY+2), (gX+3,gY+2), (gX+3,gY+2)。如果其旋转一次,旋转后的形状如图
    □■□□
    □■□□  10111213
    □■□□
    □■□□
    那么其旋转后的各方块坐标 (gX+1,gY+0), (gX+1,gY+1), (gX+1,gY+2), (gX+1,gY+3)

    如果我们将各形状编号,比如■■■■编号0,其旋转90度以后的形状为编号1
    那么0旋转目标为1,1的旋转目标为0
    所以所有形状便得到了统一,如图:

               形状编号_相对坐标_旋转后的形状编号

    (由于排版的问题,一下小方格都应该是左对齐的)
    □□□□
    □□□□
    ■■■■  0_02122232_1
    □□□□
     
    □■□□
    □■□□  1_10111213_0
    □■□□
    □■□□

    □□□□
    ■□□□
    ■□□□  2_00010212_3
    ■■□□ 

    □□□□
    □□□□
    ■■■□  3_02122203_4
    ■□□□

    □□□□
    ■■□□ 
    □■□□  4_01111213_5
    □■□□

    □□□□
    □□■□
    ■■■□  5_21021222_2
    □□□□

    □□□□
    □□□□
    ■■□□
    ■■□□  6_02120313_6

    □□□□
    □■□□
    ■■□□  7_11021203_8
    ■□□□   

    □□□□
    ■■□□
    □■■□  8_01111222_7
    □□□□

    □□□□
    □■□□
    □■□□  9_11120313_10
    ■■□□ 

    □□□□
    ■□□□
    ■■■□  10_01021222_11
    □□□□

    □□□□
    □■■□
    □■□□  11_11211213_12
    □■□□

    □□□□
    □□□□
    ■■■□  12_02122223_9
    □□■□

    □□□□
    ■□□□
    ■■□□  13_01021213_14
    □■□□

    □□□□
    □■■□  14_11210212_13
    ■■□□
    □□□□

    □□□□
    □■□□
    ■■■□  15_11021222_16
    □□□□

    □□□□
    □■□□
    □■■□  16_11122213_17
    □■□□

    □□□□
    □□□□
    ■■■□  17_02122213_18
    □■□□                                                                                                                                                      

    □□□□
    ■□□□
    ■■□□  18_11021213_15 
    □■□□


    数据结构
    5.1,网格坐标:并没有提供这样一个类或结构,但提供了网格坐标与象素坐标的转换公式:
       pCoordinate = gCoordinate*(widthOfGird+distanceOfGird);
       象素坐标值 = 网格坐标值×(网格边长+网格间距)

    5.2,网格点(GirdPoint):一个结构,它表示一个以网格坐标为坐标的点
    public struct GirdPoint
    {
      public int X;
      public int Y;
            //…各种构造函数
    }

    5.3,块(Block):继承于System.Windows.Forms.Label,用它来生成组成形状的块对象
    public class Block: System.Windows.Forms.Label
    {
      private GirdPoint gLocation;// 网格坐标
      //获取或设置 网格坐标值(若设置,那么其象素坐标会自动变化为相应值)
             public GirdPoint GLocation
         {
       get
       {
        return this.gLocation;
       }
       set
       {
        this.gLocation = value;
                       //其中Globals.ToPcoordinate()是一个将网格坐标值转换成象素坐标值的函数
        this.Location = new Point( Globals.ToPCoordinate(this.gLocation.X),
         Globals.ToPCoordinate(this.gLocation.Y));
       }
      }
            //……(各种构造函数)
    }
    对块位置的操作只需要指定其网格坐标,其象素坐标会自动变化为相应值

    5.4,形状(Shape):一个结构,由它保存游戏中的形状的相关信息
    public struct Shape
    {
       //获取或设置该形状的索引值
       public int Index;
       //获取或设置一个数组,其为该形状的相对于基础点的相对网格坐标组
       public int[] DCoordinates;
      // 获取或设置该形状旋转后的新形状的索引
       public int EddiedIndex;
       public Shape(int index, int[] dCoordinates, int eddiedIndex)
       {
           this.Index = index;
           this.DCoordinates = dCoordinates;
          this.EddiedIndex = eddiedIndex;
    }

    5.5,背景方块数组(BackBlocks):一个Block[,],其维持着23行10列的Block对象,这些对象将作为屏幕的背景,当下落的形状下落到底部时,形状所在位置的背景方块将被点亮,这表现为下落的形状被固定住了

    5.6,网格数组(GirdArray):一个int[,],其维持着整个屏幕的背景方块的状态,当该数组中的某个元素值由0变为1时,表示在屏幕中的该位置被某个方块占据,当由1变为0是,表示在屏幕中的该位置被释放而未被占据。被占据表现为屏幕上该位置已经有方块了。

    5.7,动态方块数组(DynamicBlocksArray):一个Block[4],其维持着四个Block对象,游戏中的任何运动的形状都是由这四个Block对象变化位置而得到的

    5.8,预览网格数组(ViewGirdArray):一个int[,],于GirdArray一样,只不过它维持着屏幕旁边的用于预览下一个即将出项的形状的小屏幕

    5.9,预览方块数组(ViewDynamicBlocksArray):一个Block[4],其维持着四个Block对象,游戏中屏幕普遍的小屏幕显示了下一个即将出现的形状,而这个预览形状便是由这四个Block对象变化位置而得到的

    5.10,Shapes:一个Shape[19],它维持着游戏中的19个形状
        public static readonly Shape[] Shapes = new Shape[19]
       {
        new Shape(0,new int[8]{0,2,1,2,2,2,3,2},1),
        new Shape(1,new int[8]{1,0,1,1,1,2,1,3},0),
        new Shape(2,new int[8]{0,0,0,1,0,2,1,2},3),
        new Shape(3,new int[8]{0,2,1,2,2,2,0,3},4),
        new Shape(4,new int[8]{0,1,1,1,1,2,1,3},5),
        new Shape(5,new int[8]{2,1,0,2,1,2,2,2},2),
        new Shape(6,new int[8]{0,2,1,2,0,3,1,3},6),
        new Shape(7,new int[8]{1,1,0,2,1,2,0,3},8),
        new Shape(8,new int[8]{0,1,1,1,1,2,2,2},7),
        new Shape(9,new int[8]{1,1,1,2,0,3,1,3},10),
        new Shape(10,new int[8]{0,1,0,2,1,2,2,2},11),
        new Shape(11,new int[8]{1,1,2,1,1,2,1,3},12),
        new Shape(12,new int[8]{0,2,1,2,2,2,2,3},9),
        new Shape(13,new int[8]{0,1,0,2,1,2,1,3},14),
        new Shape(14,new int[8]{1,1,2,1,0,2,1,2},13),
        new Shape(15,new int[8]{1,1,0,2,1,2,2,2},16),
        new Shape(16,new int[8]{1,1,1,2,2,2,1,3},17),
        new Shape(17,new int[8]{0,2,1,2,2,2,1,3},18),
               new Shape(18,new int[8]{1,1,0,2,1,2,1,3},15),
                  }


    功能的实现:
    6.1,形状的生成:随机生成形状编号,并根据该编号生成相应的形状
       设形状编号为indexOfShape,组成形状的四个动态方块已存在DynamicBlockArray中,基础点为BasePoint,所有形状已存在Shapes中
    void AssembleShape(int indexOfShape)
    {
     //重新安排四个动态块的位置以形成新形状
       DynamicBlocksArray[0].GLocation = new GirdPoint(
                      Shapes[indexOfShape].DCoordinates[0] + BasePoint.X,
                   Shapes[indexOfShape].DCoordinates[1]+ BasePoint.Y);

       DynamicBlocksArray[1].GLocation = new GirdPoint(
        Shapes[indexOfShape].DCoordinates[2] + asePoint.X,
                   Shapes[indexOfShape].DCoordinates[3]+ BasePoint.Y);

       DynamicBlocksArray[2].GLocation = new GirdPoint(
                  Shapes[indexOfShape].DCoordinates[4] + BasePoint.X,
                  Shapes[indexOfShape].DCoordinates[5] + BasePoint.Y);

       DynamicBlocksArray[3].GLocation = new GirdPoint(
           Shapes[indexOfShape].DCoordinates[6] + BasePoint.X, 
           Shapes[indexOfShape].DCoordinates[7] + BasePoint.Y);
    }

    6.2,形状的移动,先试探能否向指定方向移动如果能,那么移动,否则不移动。试探,由于左右下三个方向移动的本质是一样的,所以它们可以由统一的函数来实现。移动,则是通过移动基础点位置,然后根据基础点位置重新安排一下四个动态方块的位置来实现,即,移动基础点,然后调用一次上面的AssembleShape函数。
         试探:设当前形状编号为indexOfShape,arg参数指定要试探的方向 ’L’,’R’,’D’ 分别为左,右,下;基础点为BascPoint,所有形状已存再Shapes中,CountOfTier为屏幕网格的列数。取得其试探位置的网格坐标,如果该位置已经超出屏幕或该位置已经被其他方块占据,则试探的位置不可达,如果组成形状的四个方块中有一个方块的试探位置不可达,那么试探函数返回false,说明不可向指定方向移动
    private static bool canMove(int indexOfShape, char arg)
    {
     try
     {
         GirdPoint tempBasePoint;

         switch(arg)
      {
       case 'L':
           tempBasePoint = new GirdPoint(BasePoint.X-1,BasePoint.Y);
          break;
       case 'R':
           tempBasePoint = new GirdPoint(BasePoint.X+1, BasePoint.Y);
          break;
       case 'D':
        tempBasePoint = new GirdPoint(BasePoint.X, BasePoint.Y+1);
        break;
               case 'E'://不移动,用于判断能否旋转,
                           //与判断移动不同的是传入的indexOfShape,
                           //判断旋转用的是旋转后的形状的索引,而移动用的是当前的
                        tempBasePoint = BasePoint;
                      break;
       default:
        MessageBox.Show("错误的参数"+arg+"\n应该为'L'或'R'或'D'");
        return false;
      }
        
      int gX0 = Shapes[indexOfShape].DCoordinates[0]+tempBasePoint.X;
      int gY0 = Shapes[indexOfShape].DCoordinates[1]+tempBasePoint.Y;
      int i =  GirdArray[gY0,gX0];
                
      int gX1 = Shapes[indexOfShape].DCoordinates[2]+tempBasePoint.X;
      int gY1 = Shapes[indexOfShape].DCoordinates[3]+tempBasePoint.Y;
      int j = GirdArray[gY1,gX1];

      int gX2 = Shapes[indexOfShape].DCoordinates[4]+tempBasePoint.X;
      int gY2 = Shapes[indexOfShape].DCoordinates[5]+tempBasePoint.Y;
      int m = GirdArray[gY2,gX2];

      int gX3 = Shapes[indexOfShape].DCoordinates[6]+tempBasePoint.X;
      int gY3 = Shapes[indexOfShape].DCoordinates[7]+tempBasePoint.Y;
      int n = GirdArray[gY3,gX3];

      //i,j,m,n为其即将到达的新位置的网格值,若为1,说明该网格已被占据  
      if(gX0<0 || gX0>= CountOfTier ||  i == 1 ||
           gX1<0 || gX1>= CountOfTier || j == 1 ||
       gX2<0 || gX2>=  CountOfTier || m == 1 ||
       gX3<0 || gX3>= CountOfTier || n == 1)
      {
       return false;
      }
      
        }
        catch
        {
         return false;
        }

        return true;
    }
         移动:移动基础点,并重新组成形状,比如左移,
    void ToLeft(int indexOfShape)
    {
        if(canMove(indexOfShape,’L’)
        {
      BasePoint = new GirdPoint(BasePoint.X-1, BasePoint.Y);
      AssembleShape(indexOfShape);
        }
    }

    6.3,自动下落,设一个计时器timer,其每隔一定时间调用一次ToDown函数
    void ToDown(int indexOfShape)
    {
        if(canMove(indexOfShape,’D’)
        {
      BasePoint = new GirdPoint(BasePoint.X, BasePoint.Y+1);
      AssembleShape(indexOfShape);
        }
        else
        {
            //其他,比如固定下落的形状,判断得分,判断游戏是否已经结束等等
        }
    }

    6.4,下落形状的固定,当试探到不能下落时,就固定住当前形状。其实组成形状的四个块并没有被固定住,而是当它们当前位置对应的四个背景方块被点亮后,它们离开了当前位置而到屏幕的顶部(0~2行,不可见)组合新形状去了。对应的四个背景方块被点亮表现为形状被固定
    void FixShape()
    {
     for(int i=0; i<4; i++)
     {
            //将该位置的背景方块点亮(变为指定的颜色,假设为ColorOfFixedBlock)
           BackBlocks[DynamicBlocksArray[i].GLocation.Y, 
                     DynamicBlocksArray[i].GLocation.X].BackColor = ColorOfFixedBlock;
            //将对应的网格值设置为1,表示此网格已经被占据
           GirdArray[DynamicBlocksArray[i].GLocation.Y, DynamicBlocksArray[i].GLocation.X] = 1
         }
        //其他,比如判断是否应该消行加分等
        //将基础点移动到顶部,并产生新形状等等
    }

    6.5,旋转,与移动的原理一样。先试探能否旋转,如果能的话,取得旋转后的形状的索引,并按照此索引调用函数AssembleShape()。
        试探能否旋转:很巧妙地运用了试探能否移动的CanMove函数。与移动不同的是,试探能否旋转传入的是旋转后的形状的索引
    bool CanEddy(indexOfShape)
        {
           int eddiedIndex = AllShapes.Shapes[indexOfShape].EddiedIndex;//旋转后的新形状的索引
           return canMove(eddiedIndex,’E’);
        }
       旋转:很巧妙地运用了移动时的AssembleShape()
       void Eddy(indexOfShape)
       {
           if(CanEddy(indexOfShape)
           {
              int eddiedIndex = AllShapes.Shapes[indexOfShape].EddiedIndex;//旋转后的新形状的索引
              AssembleShape(eddiedIndex);
           }
       }
    这是激动人心的,程序巧妙地将左移、右移、下移、旋转高度地统一了起来,它们的实质都是调用CanMove()函数进行判断,然后再调用AssembelShape()函数移动四个动态方块的位置,而表现出各种效果。多么棒的设计啊(呵呵,自我欣赏了)

    6.6,消行,当形状被固定后需要判断是否有满行,如果有,则消去已满的行
        扫描是否存在满行:没有必要扫描整个屏幕,而只需要扫描当前被固定的形状的基础点一下的四行(但不能超过第22行,因为第23行始终是满的)。对于某一行而言,如果该行的GirdArray对应值均为1,说明该行已被全部占据(或者说,如果该行的GirdArray对应值至少有一个为1,说明该行没有被全部占据),那么应该消去该行
        void ScanFullLines(int lineStart,int lineEnd)
        {
              int countOfFullLine = 0;//记录此次消除的行数,以便加分

           for(int i= lineStart; i<=lineEnd; i++)
           {
       bool isFull = true;//指示是否已被填满
       for(int j=0; j<CountOfTier; j++)//countOfTier:屏幕列数
       {
        if(GirdArray[i,j]==0)
        {
         isFull = false;
        }
       }

       if(isFull)
       {
        countOfFullLine++;
        DelLine(i);//消去第i行
              }
       
       switch(countOfFullLine)
                  {
                     //根据所消行数加分
                  }
           消去指定行:将该行上的背景方块熄灭(与点亮相反,变为另一种颜色,设为ColorOfBackBlock),并将该行以上的所有方块都下移一格。设背景方块数组为BackBlocks,网格数组为GirdArray
    void DelLine(int indexOfLine)
    {
     //熄灭该行上的背景块,表现为移出了该行上的块
     for(int i=0; i<CountOfTier; i++)
     {
             //熄灭对应的背景方块
      BackBlocks[indexOfLine,i].BackColor = ColorOfBackBlock;
      //释放被占据的网格
      GirdArray[indexOfLine,i] = 0;
         }
     //该行以上的所有行整体下落
     for(int i=indexOfLine-1;i>=3; i--)
     {
      for(int j=0; j<CountOfTier; j++)
      {
       if(GirdArray[i,j] == 1)
       {
        //此块熄灭,其正下方的块点亮,表现为下落了一格
        GirdArray[i,j] = 0;
        BackBlocks[i,j].BackColor =.ColorOfBackBlock;
        GirdArray[i+1,j] = 1;
        BackBlocks[i+1,j].BackColor = ColorOfFixedBlock;
       }
        }
        } 
    }

    6.7 游戏存档,当用户欲保存当前游戏以供以后继续时,使用这个功能。需要保存的数据有:目前的速度级别,目前的分数,目前的已屏幕上已被固定的各形状。存档文件仅需要一百多个字节就可以了,是这样做的:第一行写入游戏速度级别,第二行写入游戏分数,从第三行开始写入游戏中网格数组(GirdArray),网格数组中为1的对应的背景方块数组的方块即是被固定的(点亮的)。
    bool SaveGame(string fileName)
    {
     try
     {
              StreamWriter sWriter =     
                  new StreamWriter(fileName,false,System.Text.Encoding.Default, 100);

      //写入速度级别
      sWriter.WriteLine(SpeedLevel.ToString());

      //写入分数
      sWriter.WriteLine(Score.ToString());

      //写入背景网格状态
      for(int i=0; i<CountOfRow; i++)
      {
       for(int j=0; j< CountOfTier; j++)
       {
        sWriter.Write(GirdArray[i,j].ToString());
       }
      }

      sWriter.Close();
     }
     catch(Exception ex)
     {
      MessageBox.Show("未能保存!\n原因是:\n"+ex.Message);
      return false;
     }

        return true;
    }
    比如我的一个游戏存档如下:
    1
    1500
    0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
    0000000000000000000000000000000000000000000000000000000000000000000000000000010000000001100000
    0001101000110110100111011111111101111111111111111111

    6.8 加载游戏存档,将游戏存档读出,并将游戏中的各变量更改为存档中的值就可以了。
           bool LoadGame(string fileName)
           {
       try
       {
        StreamReader sReader =
         new StreamReader(fileName,System.Text.Encoding.Default,false,100);

         //加载游戏速度
        string strSpeedLevel = sReader.ReadLine();
        Level = int.Parse(strSpeedLevel));

        //加载游戏分数
        string strScore = sReader.ReadLine();
        Score = int.Parse(strScore);
        
        //加载游戏网格状态
        char[] buffer = new char[1];
        for(int i=0; i<CountOfRow; i++)
        {
         for(int j=0; j<CountOfTier; j++)
         {
          sReader.Read(buffer,0,1);
          GirdArray[i,j] = int.Parse(buffer[0].ToString());
                               //如果对应值为1,则点亮对应的背景网格
          if(GirdArray[i,j]==1)
          {
           BackBlocks[i,j].BackColor = ColorOfFixedBlock;
          }
          else
          {
           BackBlocks[i,j].BackColor = ColorOfBackBlock;
          }
         }
        }

        sReader.Close();
       }
       catch(Exception ex)
       {
        MessageBox.Show("加载游戏失败\n原因是:\n"+ex.Message);
        return false;
       }

       return true;
      }

    -----------------------------------------

    如果你需要了解更多的细节问题,可以在这里下载源代码
     
    https://files.cnblogs.com/zhouyinhui/Tetris2.rar

  • 相关阅读:
    iOS 动画总结UIView动画
    iPhone 本地通知
    NSNotification学习笔记
    [重构]把程序写得更简洁,更好维护
    使用asp:Timer控件为站点创建一个实时时钟
    为用户控件(UserControl)写属性
    Gridview前面10行数据显示背景色
    MS SQL获取最大值或最小值日期的函数
    How to modify Inventory Aging Report form days field default value
    DropDownlist的DataTextField显示多列数据
  • 原文地址:https://www.cnblogs.com/zhouyinhui/p/392037.html
Copyright © 2020-2023  润新知