文章目录
引言(废话)
游戏玩法
基本要求
实现原理
游戏逻辑
棋盘滑动
合成数字
生成新方块
计分机制
调整棋盘大小
游戏的进行和结束
UI设计
棋盘显示
方块显示
游戏设置
实现源码
总结
引言(废话)
2048想必大伙都玩过,玩过的可以直接跳到实现原理那一节。现在网上用编程语言写2048的也是一抓一大把,虽说借鉴参考是好事,但我更想证明一下自己,挑战0参考搞小项目,就从这个最简单的2048开始做起了。(唯独写UI的时候有些地方搞不懂就去网上查了,比如棋盘在panel居中、防止窗体被重复打开等问题)要想提高编程水平,掌握技术,必须先学会独立思考。
愿意交流学习的同学可以直接跳到源代码部分开始阅读,或者把我的项目下载出来跑一下。本人水平仍然有限,有很多不对的地方还请大佬多多指出,大家共同进步,感谢支持。
游戏玩法
玩家通过WASD(或者↑↓←→)键操作整个4×4棋盘(英文:grid)的所有方块(英文:tile)向某个方向滑动(后面简称为棋盘滑动),两个相同数字的方块在靠拢、碰撞时数字会相加。每次滑动后,系统会随机选取一个空白格子生成数字2。当棋盘没有空白格子,并且找不到相邻的两个相同数字时,游戏结束。游戏的目标是不断地让相同数字相加,直到合成出2048。
基本要求
在窗体中正确显示棋盘、当前游戏分数以及最佳纪录。
确保底层逻辑准确无误,能够让游戏顺利进行;当玩家合成出2048或无路可走时,程序提示游戏结束。
用户可以自定义棋盘大小,玩到6×6或8×8等规格的2048。窗体大小应根据棋盘大小自动调整。
实现原理
游戏逻辑
棋盘滑动
这里使用一个二维数组(列表)来模拟整个棋盘,各个元素存储的是对应方块中的数字,为0则是空白方块。所以,我们的棋盘是长这样的:
我们知道,在众多编程语言(如C/C++、C#等)中,二维数组是以行序为主的方式存储各个元素的(很多教程又叫做按行排列),即先存储行,再存储列。也可以这么理解——二维数组是由多个长度相同的一维数组构成的,每个一维数组都是二维数组中的一行。
每一次滑动都会改变棋盘中许多方块的位置,这涉及到对各个行(列)的元素移动操作,使用循环是非常容易实现的,比如左右滑动就是遍历各行,使每一行的元素沿着左右方向移动,向数组边界靠拢;而上下滑动就是遍历各列,使每一列的元素沿着上下方向移动。说了这么多,那具体到底该如何做呢?举个简单的例子:
不难推出,当棋盘往上(下)方向滑动时,所有列上的方块沿着行数递减(递增)的方向移动;往左(右)方向滑动时,所有行上的方块沿着列数递减(递增)的方向移动。如上图所示,当用户按下D或→键时,棋盘中的两个方块2向右滑动,到达棋盘边界。此时,第一个方块从第0行第1列移动到第0行第3列,第二个方块从第1行第0列移动到第1行第3列。在底层用二维数组表示是这样的:
这是最简单的情况:①每一行都仅有一个方块;②当某个边界没有方块时,棋盘中的方块向这个边界滑动后,就会聚集到这个边界上。所以我们的代码可以这样写:
public void Swipe()
{
for (int i = 0; i < 4; i++)
{
for (int j = 3; j >= 0; j--)
{
if (Grid[i, j] != 0)
{
Grid[i, 3] = Grid[i, j];
Grid[i, j] = 0;
}
}
}
}
注意:C#里的多维数组要写成[,]这种形式,而不是[][]!
再来看另一种情况:①有两个方块出现在同一行;②有两个数字相同的方块即将碰撞。如下图所示:
这次右边界上有一个方块4,棋盘向右滑动时,第0行第1列的方块2到达方块4左侧。在滑动之前这两个方块之间是有空隙的,滑动后两者就相邻了。(这里先不考虑数字合成,后面再讲)其实这很好理解,我们可以想象整个棋盘是在三维空间内的,棋盘往某个方向倾斜,上面的所有方块就往这个方向滑动,这意味着对于原本就在边界上聚集的方块,它们的位置依旧不变,而跟这些方块之间存在距离的其它方块,就会向它们靠拢。
对于第0行,最靠右边的是位于第3列的方块4,所以我们要从第2列开始向左遍历,寻找会滑动的方块,将它移动到方块4的相邻左侧,再以这个方块为新的边界,寻找其它方块……言语难以表述,还是看图为好:
上图中用到了一个临时变量j和k,j用来标记要移动方块的位置,k用来标记聚集方块与空白区域的边界。这跟插入排序是不是有点像?把上面的代码改一下:
public void Swipe()
{
for (int i = 0; i < 4; i++)
{
int k = 3;
//判断右边界是否有方块聚集
for (; Grid[i, k] != 0 && k >= 0; k--);
//若该行已满或者只有一个空白格
if (k < 1)
continue;
for (int j = k - 1; j >= 0; j--)
{
if (Grid[i, j] != 0)
{
Grid[i, k--] = Grid[i, j];
Grid[i, j] = 0;
}
}
}
}
上面的函数 Swipe() 中我们假设了方块是向右滑动的。我们通常希望用一个函数来执行棋盘滑动操作,显然棋盘分别往不同方向滑动时,实现逻辑是大同小异的,实际上还是分成四个不同的函数,让 Swipe() 向外部提供一个接口,根据玩家输入的方向键让棋盘往指定方向滑动。所以代码可以改成这样:
class Game
{
//...
public void Swipe(Keys key)
{
switch (key)
{
case Keys.W:
case Keys.Up:
SwipeUp();
break;
case Keys.A:
case Keys.Left:
SwipeLeft();
break;
case Keys.S:
case Keys.Down:
SwipeDown();
break;
case Keys.D:
case Keys.Right:
SwipeRight();
break;
}
}
private void SwipeUp()
{
//向上滑动
}
private void SwipeLeft()
{
//向左滑动
}
private void SwipeDown()
{
//向下滑动
}
private void SwipeRight()
{
//向右滑动
}
}
这样一来,让棋盘向右滑动的代码就是这样的:
private void SwipeRight()
{
for (int i = 0; i < 4; i++)
{
int k = 3;
for (; Grid[i, k] != 0 && k >= 0; k--);
if (k < 1)
continue;
for (int j = k - 1; j >= 0; j--)
{
if (Grid[i, j] != 0)
{
Grid[i, k--] = Grid[i, j];
Grid[i, j] = 0;
}
}
}
}
合成数字
方块在向边界靠拢后,若出现有两个数字相同的方块相邻了,那这两个方块可以直接合成一个新的数字。上一个例子中,第1行的两个数字2在右边界发生碰撞,两者相加,得到一个新的数字4。
现在我们仍然只考虑向右滑动。比较复杂的情况是:两个相同数字的方块聚集在右边界,一个方块在左边界上,中间存在空白格子。如下图所示:
这次我们不能只考虑把左边的方块移动到右边了,因为最靠右边的这两个方块要合成一个新的方块了。上面提到变量k用来标记聚集在右边的方块跟空白区域的边界,把相同数字的合并考虑进去后,棋盘滑动的逻辑就分为两部分:①合并k右边的相同数字的相邻方块;②移动k左边的方块,边移动边合并。下面针对这两部分逻辑分开讨论:
三个方块聚集在右边,其中有两个相邻方块数字相同。
int k = 3;
for (; Grid[i, k] != 0 && k >= 0; k--);
int m = 3, n = 3;
while (k < m)
{
if (m > 0 && Grid[i, m - 1] == Grid[i, m])
{
//确保能应对包含m=n在内的所有情况
Grid[i, m] = 0;
Grid[i, n] = Grid[i, m - 1] * 2;
Grid[i, m - 1] = 0;
m -= 2;
k++;//别忘了更新k的值
}
else
{
Grid[i, n] = Grid[i, m];
Grid[i, m] = 0;
m--;
}
n--;
}
三个方块聚集在左边,仍然是有两个相邻方块数字相同。此时让棋盘向右滑动,就会变成两个4在右边。具体该怎么做呢?画图说明一切:
这就是“边滑动边合成”的思想。先考虑前三步该如何实现,编写出下面的代码:
for (int j = k - 1; j >= 0; j--, k--)
{
if (Grid[i, j] != 0)
{
Grid[i, k] = Grid[i, j];
Grid[i, j] = 0;
if (Grid[i, k] == Grid[i, k + 1])
{
Grid[i, k + 1] *= 2;
Grid[i, k] = 0;
}
}
}
讲道理,代码看上去有点臃肿,而且仔细看上面的图会发现,第三步其实是多余的。现在给它优化一下:
for (int j = k - 1; j >= 0; j--)//注意这里现在没有k--了,因为逻辑不一样了
{
if (Grid[i, j] == 0)
continue;
if (k < 3 && Grid[i, k + 1] == Grid[i, j])
Grid[i, k + 1] *= 2;
else
Grid[i, k--] = Grid[i, j];
Grid[i, j] = 0;
}
综合以上两部分逻辑,编写出下面的代码:
private void SwipeRight()
{
for (int i = 0; i < 4; i++)
{
int k = 3;
for (; Grid[i, k] != 0 && k >= 0; k--);
for (int m = 3, n = 3; k < m; n--)
{
if (m > 0 && Grid[i, m - 1] == Grid[i, m])
{
Grid[i, m] = 0;
Grid[i, n] = Grid[i, m - 1] * 2;
Grid[i, m - 1] = 0;
m -= 2;
k++;
}
else
{
Grid[i, n] = Grid[i, m];
Grid[i, m] = 0;
m--;
}
}
for (int j = k - 1; j >= 0; j--)
{
if (Grid[i, j] == 0)
continue;
if (k < 3 && Grid[i, k + 1] == Grid[i, j])
Grid[i, k + 1] *= 2;
else
Grid[i, k--] = Grid[i, j];
Grid[i, j] = 0;
}
}
}
现在考虑后两步:玩过2048的都知道,相同数字的方块只有在一次“碰撞”后会发生相加,就像上面的例子一样,同一行从左至右依次有4、2、2三个方块,向右滑动后是两个方块2合成一个4了,但原来就有的4和新合成出来的4并不会结合。然而上面的代码却会造成这种错误——它在移动方块的过程中不知道之前的方块是新合成出来的,导致在用两个2合成出一个新的4后,它仍会被拿来跟原有的4比较,发现相等后就直接合并,生成一个8,这样的结果显然是错误的,我们不希望看到。
解决办法是再多出一个变量和一点逻辑(多占了一点小小的内存空间,却是优化的一大步),用merged去标记上一次移动方块是否有执行合成操作。把上面的代码改成: