Hex 2048 - 数字游戏 - C#2022





5.00/5 (13投票s)
流行数字图块滑动游戏的图形化、有活力且多彩的版本
恶意源代码
与普遍的看法相反,2048 的原始游戏并非由美国军方研究机构 Darpa 开发,而是由 Gabriele Cirulli 在麻省理工学院秘密开发的。鉴于该游戏的成瘾性,它已被列为一种武器化分心之物,被释放到全世界不设防的青少年、疲惫的母亲和酩酊大醉的本科生身上,这使得 Cirulli 实际上是一名伪装成 19 岁大学生的中央情报局特工的未经证实的指控得到了证实。因此,我抓住这个机会,终于给地下编程社区和 Kiddie Coders 的成员留下深刻印象(同时耐心地等待我的匿名身份证和会员卡寄来),卸下了我负责任的软件开发者的身份,并着手以一种更具杀伤力的“游戏”形式构建下一个值得成为模因的诱惑,从而希望进一步美化其设计者所说的:
"开源软件之美的一部分”。
您可以通过阅读这篇 维基百科文章 了解更多关于玩游戏的信息。
或者您可能对观看我制作的关于此项目的 简短视频 感兴趣,该视频已发布在 YouTube 上。
免责声明
由于这款令人上瘾的产品会导致极高的“浪费时间”概率,健康与安全委员会建议使用制造商提供的“健康生活安全守则”计时器选项,以帮助最大程度地减少因长时间使用本产品可能产生的任何不良影响。
Hex2048 的设计者 Christ Kennedy 对用户在社交、婚姻或职业生活中遭受的任何损害概不负责。
谨慎使用。
板
称其为“方块滑动游戏”有点名不副实,当你审视这款毁掉 GPA 的代码的内部工作原理时。这个“家破人亡者”实际上并没有进行任何“方块滑动”,因为方块本身从初始化开始就保持在其分配的位置,直到你听到你所爱的人砰地关上前门,然后没有你开车离开。原始游戏的四线性布局使得在保留值的 \((x, y)\) 笛卡尔坐标系中移动各个方块成为可能,但此版本采用六边形布局,方块可以向六个方向而非四个方向移动。每个方块都属于 classTile
类,并拥有一系列指向其自身邻居的指针。这有一些便利之处,但这意味着在棋盘上移动它们将是一件麻烦事,而且会浪费那些漂亮的指针。另外,由于方块本身会在各自的基座上晃动,因此很容易为它们指定一个位置,并为它们设置一个径向坐标约束,让它们可以在不迷路或偏离太远的情况下移动。
我在编写此应用程序时发现的一件棘手的事情(并且仍然能够在 5 小时内完成基本功能并运行 - 下载 Hex_2048_fundamentals.zip - 67 KB 文件是我在第一个编码夜晚结束时完成的功能快照)是,由于列相对于它们的邻居是垂直偏移的,因此在奇数列中向水平移动与在偶数列中向水平移动是不同的。看看下面的 Move()
函数,它处理了这个问题。
public static Point move(Point pt, int dir)
{
if (lstPtMoveDir.Count == 0)
{
List<Point> lstEven = new List<Point>();
{
lstEven.Add(new Point(0, -1));
lstEven.Add(new Point(1, -1));
lstEven.Add(new Point(1, 0));
lstEven.Add(new Point(0, 1));
lstEven.Add(new Point(-1, 0));
lstEven.Add(new Point(-1, -1));
}
lstPtMoveDir.Add(lstEven);
List<Point> lstOdd = new List<Point>();
{
lstOdd.Add(new Point(0, -1));
lstOdd.Add(new Point(1, 0));
lstOdd.Add(new Point(1, 1));
lstOdd.Add(new Point(0, 1));
lstOdd.Add(new Point(-1, 1));
lstOdd.Add(new Point(-1, 0));
}
lstPtMoveDir.Add(lstOdd);
}
return new Point(pt.X + lstPtMoveDir[pt.X % 2][dir].X,
pt.Y + lstPtMoveDir[pt.X % 2][dir].Y);
}
初始化两个奇偶点列表后,它会返回需要对调用函数进行的更改,以便点从输入参数 pt
按照所需的 dir
方向移动。有六个方向,零指向北方,其余方向按顺时针旋转。在此游戏中,奇数列比偶数列高,如下所示。
找到你的路
广度优先搜索算法是该项目的核心部分。它的应用多种多样。它速度快,易于实现,并且在线上有大量的文档和教程可以帮助您。我们可以快速看一下 Hex2048 的一个例子。在下面的图像中,顶部的蓝色方块(编号 15)需要绕过被黄色方块占据的区域,到达左下角的最终位置(编号 0)。为此,我们从目的地开始,并在离开搜索地点时在方块上留下便签,计算我们离开地点后经过的步数。(在不同的应用中,您会从起点开始并分支,直到找到您要找的东西,而不是像我们在这里那样,从您打算“找到”的位置开始,因为我们不是在寻找某种“东西”,而是在寻找通往特定目的地的路径。)当您到达起点位置(顶部的蓝色方块)时,该方块将被贴上一张便签,上面写着 15,即到达那里所经过的步数。要找到从那里到目的地方块的路径,您只需倒数并移动到任何一个(在大多数情况下是唯一的)带有数字比您所站方块少一的数字的方块。
请注意,当您到达路径上编号为六的方块时,有两个相邻的方块上标有数字五。该算法可以选择其中任何一个,仍然可以到达其目的地。有时,就像在视频游戏中一样,您可能会随机选择可用选项,以保持新鲜感。其他时候,您只是想快速完成,并选择遇到的第一个合适的方块。这一切都取决于您的 Uber 司机。
下面的代码取自 Hex2048,并返回棋盘上一个方块需要沿着移动才能到达目的地的点位置列表。此实现按相反的顺序工作,从开始计算到目的地,然后返回,然后取最终结果并反转点顺序以获得最终输出列表。
List<Point> MoveSelectedTile_BFS()
{
Point ptDestination = ptTileHighLight;
Point ptStart = ptTileSelected;
int[,] intSeen = new int[szGame.Width, szGame.Height];
// init intSeen
for (int intX = 0; intX < szGame.Width; intX++)
for (int intY = 0; intY < szGame.Height; intY++)
intSeen[intX, intY] = -1;
List<Point> lstQ = new List<Point>();
lstQ.Add(ptStart);
intSeen[ptStart.X, ptStart.Y] = 0;
int intStepsTaken = 0;
while (lstQ.Count > 0)
{
Point ptTile = lstQ[0];
lstQ.RemoveAt(0);
intStepsTaken = intSeen[ptTile.X, ptTile.Y];
for (int intDirCounter = 0; intDirCounter < 6; intDirCounter++)
{
Point ptNeaghbour = move(ptTile, intDirCounter);
if (TileInBounds(ptNeaghbour))
{
if (intSeen[ptNeaghbour.X, ptNeaghbour.Y] < 0)
{ // BFS has not seen neaghbour
if (Board.Tiles[ptNeaghbour.X, ptNeaghbour.Y].Value == 0)
{ // neaghbour is not occupied by a colored tile
intSeen[ptNeaghbour.X, ptNeaghbour.Y] = intStepsTaken + 1;
if (ptNeaghbour.X == ptDestination.X &&
ptNeaghbour.Y == ptDestination.Y)
{ // we have found a path to the destination
lstQ.Clear();
goto validPath;
}
else
{
lstQ.Add(ptNeaghbour);
}
}
}
}
}
}
return new List<Point>();
validPath:
intStepsTaken = intSeen[ptDestination.X, ptDestination.Y];
List<Point> lstSteps = new List<Point>();
lstSteps.Add(ptDestination);
Point ptCurrent = ptDestination;
while (!(ptCurrent.X == ptStart.X && ptCurrent.Y == ptStart.Y))
{
for (int intDirCounter = 0; intDirCounter < 6; intDirCounter++)
{
Point ptNeaghbour = move(ptCurrent, intDirCounter);
if (TileInBounds(ptNeaghbour))
{
if (intSeen[ptNeaghbour.X, ptNeaghbour.Y] == intStepsTaken - 1)
{
lstSteps.Add(ptNeaghbour);
ptCurrent = ptNeaghbour;
intStepsTaken--;
break;
}
}
}
}
lstSteps.Reverse();
lstSteps.RemoveAt(0);
return lstSteps;
}
当游戏需要测试棋盘内容并移除四个或更多相似方块的组时,会以略微不同的方式使用相同的算法。该函数从棋盘上的每个方块开始,并分支到具有相同值的相邻方块。当找到四个或更多这样的方块时,它会以列表的形式返回它们,然后从棋盘上移除,直到不再找到相似方块的组,游戏才会继续。
这是一个更简单的函数。看看任何给定种子方块 ptSeed
的 BFS。
List<Point> Tiles_GatherLike_BFS(Point ptSeed)
{
if (!TileInBounds(ptSeed)) return new List<Point>();
int[,] intSeen = new int[szGame.Width, szGame.Height];
int intSeedValue = Board.Tiles[ptSeed.X, ptSeed.Y].Value;
if (intSeedValue <= 0) return new List<Point>();
List<Point> lstQ = new List<Point>();
lstQ.Add(ptSeed);
List<Point> lstRetVal = new List<Point>();
while (lstQ.Count > 0)
{
Point ptTest = lstQ[0];
intSeen[ptTest.X, ptTest.Y] = 1;
lstQ.RemoveAt(0);
int intTileValue = Board.Tiles[ptTest.X, ptTest.Y].Value;
if (intTileValue == intSeedValue)
{
if (!lstRetVal.Contains(ptTest))
{
lstRetVal.Add(ptTest);
for (int intDir = 0; intDir < 6; intDir++)
{
Point ptNeaghbour = move(ptTest, intDir);
if (TileInBounds(ptNeaghbour))
if (intSeen[ptNeaghbour.X, ptNeaghbour.Y] == 0)
if (!lstQ.Contains(ptNeaghbour) && !lstRetVal.Contains(ptNeaghbour))
lstQ.Add(ptNeaghbour);
}
}
}
}
return lstRetVal;
}
抱怨和隆隆声
玩这个游戏时,您会注意到的第一件事是方块在移动时互相挤压的方式。这种效果很酷。正如我之前提到的,每个方块都通过一个径向坐标(这类似于一个球坐标系,但缺少第三个垂直坐标 Phi)固定在它的位置上。本质上,它是一个具有特定长度的箭头。箭头指向方块偏离其固定中心位置的方向,长度表示它在该方向上移动的距离。但是方块一次只移动一点点,所以它们需要知道移动多少以及移动多远。在每个时钟周期,径向坐标的幅度都会增加,直到达到其限制,然后开始缩短,直到再次为零,方块才静止。当它达到最大限制时,该限制和径向坐标的方向用于“推动”与其移动方向相同的邻近方块。在其移动方向上的三个邻居分别受到与其碰撞力的三分之一的力,并且通过这种方式,方块的运动从源头向外辐射。
液体和空气都以类似的方式运行。声波实际上只是大气粒子之间的碰撞,并传播施加到每个邻居的力。能量守恒意味着每个粒子感受到的力都分布到其所有邻居上。在这里,我们的方块在一个六个量化方向中的一个方向上移动。当它们达到该移动的末端时,它们会稍微向左推动一个邻居。
(dir + 5) % 6
并稍微向右推动另一个邻居
(dir +1) % 6
以及一个与自身被推动方向相同的邻居。
这听起来都很好,但实际上在实现中,向左进行的推动会沿着它们来的方向回溯,从而产生一种类似“谐振”的情况。如果您不明白我的意思,请观看这个关于塔科马大桥倒塌的视频。游戏板上可能没有狂风呼啸,但这仍然是一个问题。当我把这个功能整合在一起时,一个小小的碰撞就会让整个棋盘飞出支架。我解决这个问题的方法是为每个方块添加一个点列表,以跟踪是哪个方块引发了朝向它们运动的波。当它们被邻居撞击时,邻居会报告是哪个方块引发了撞击它们的力,而被撞击的方块会查看其列表,看看它最近是否被该方块的脉冲撞击过。如果源方块的位置(ID 点)不在列表中,则方块会被撞击并将其源点添加到其列表中。当潮汐力绕着棋盘移动并试图再次撞击它时,第一次和所有后续的撞击都会被忽略。每个方块都会向前传播相同的源方块 ID,当它将感受到的脉冲传播给邻居时,并且每个方块在静止时都会清空其源方块 ID 列表。
调整 PerPixelAlpha 窗体大小
我最初打算在游戏两侧添加龙的精灵。考虑到这一点,我将一张我从网上下载的龙的图片剪切成了一个可接受的精灵。我选择的蛇形龙不如非蛇形龙适合制作精灵,所以效果不如预期的好,但真正的问题出现在我开始绘制游戏左侧的动画龙时。左侧的龙之所以有问题,是因为龙的形状和大小在移动时会发生变化。当然,这也是制作成精灵的目的,但由此产生的窗体图像大小也会变化,这意味着窗体必须移动以适应这些变化。移动窗体对于像我十年前发布的Jessica Rabbit 精灵窗体这样的小型精灵来说是可以的,但对于这个互动游戏来说,结果是不可接受的。游戏在图像改变后以延迟的速率明显跳动,而且非常糟糕,我不得不实现一个双窗体系统,在一个窗体上绘制下一帧,将其定位,然后切换关闭前一个(Hide()
),然后立即切换开启下一个(Show()
)。使用此实现,图形效果好得多,但事实证明鼠标事件触发器并未按计划进行,游戏变得没有响应。因此,我编写了几行代码,每隔游戏循环(在计时器上)调用一次,测试鼠标的位置和按钮状态,然后决定调用哪个事件,而不是让两个跳动的窗体争夺特权,并在它们争吵的苦涩中忽略报告鼠标事件的职责。这效果还不错,而且效果更好,但有很多的复杂性,代码也变得非常丑陋,我看了看我的龙(同一个精灵的两个镜像),决定不值得。我将窗体的大小设置为一个恒定的值,这个值足够大,可以容纳两条龙在最糟糕的情况下,我看了看我所做的工作,觉得还不错。
但还不够好。
窗体变大后降低了游戏速度,而且那些龙根本不值得。所以我把它们删掉了。我花了十分钟就调整了整个项目,没有那些丑陋的龙,我们现在拥有我们现在所拥有的… 好太多了。
闪烁 - 星星、尖刺和铁蒺藜
星星、尖刺和铁蒺藜是游戏变得兴奋时飞出的三种不同形状,呈现出多种颜色。它们都是在游戏首次启动时动态绘制的,然后它们各种颜色和旋转的版本都存储在硬盘上的二进制流文件中。一个单一的算法绘制所有这三种形状,它们仅在“尖刺”的数量上有所不同。星星有 5 个点,铁蒺藜(不确定我为什么这么叫?!?)有四个,尖刺只有两个。它们各有内半径和外半径,通过围绕中心点旋转并以交替半径设置点来生成点。我说了,这些图像是旋转的,并按顺序存储在文件中。在存储时,它们的地址会被记录在一个列表中,文件大小用作图像索引的开头,其中所有在列表中累积的地址都按其图像的相同顺序记录。在所有这些之后,文件最后记录的是一个长整数,它告诉你索引的起始位置。因此,要查找特定图像,您可以通过形状、颜色和角度通过此函数进行。
static long ImageAddress(enuSparkleShapes eShape, enuSparkleColors eColor, int intRotation)
{
long lngIndexAddress
= lngFileIndexAddress
+ (((int)eColor
* ((int)enuSparkleShapes._numSparkleShapes
* NumRotationPerQuarterTurn))
+ ((int)eShape
* NumRotationPerQuarterTurn)
+ intRotation) * SizeLongInteger;
fs.Position = lngIndexAddress;
return (long)formatter.Deserialize(fs);
}
它通过计算该图像 Index
的位置来确定,方法是计算它之前的索引数量,将该值乘以存储单个 long
整数在流中所需的字节数,然后将它们的乘积加到 Index
起始的 Address
(我们写在文件末尾的最后一个长整数)。
所以,它测量文件长度。后退存储一个长整数在我的硬盘上的字节数,58 字节(是的?!58…我也很惊讶,因为一个长整数只有 8 字节??)。在那里它读取长整数,就知道 Index
从哪里开始。它从索引的开头向前移动,找到我们要找的图像的地址。读取长整数并将其用作位图的地址。
困惑?
或者,将索引存储在单独的文件中有个优点,那就是您可以添加图像,并在两个单独的文件上增长您的索引和图像列表。在这种情况下,我们谈论的是微小的彩色星星图像,它们只需几秒钟即可复制,因此即使我改变主意,并将被忽略的 3 点飞镖添加到“闪烁形状”列表中,删除现有文件并重建它也没有什么大不了的。
将图像和索引都放在一个文件中可以保持整洁。
由于不同的操作系统可能会给你不同的结果,因此需要评估 SizeLongInteger
变量。这是确定存储一个长整数在你的硬盘上需要多少字节的函数。
static long SizeLongInteger = 0;
static void SizeLongInteger_Measure()
{
string strTempFilename = "DoNotTryThisAtHome.bin";
FileStream fsTemp = new FileStream(strTempFilename, FileMode.Create);
fsTemp.Position = 0;
formatter.Serialize(fsTemp, (long)1);
SizeLongInteger = fsTemp.Position;
fsTemp.Close();
System.IO.File.Delete(strTempFilename);
}
调试器和二进制字符串
当我处理那些鼠标事件问题时,我不得不弄清楚 MouseEventArgs
是如何编码的。它们似乎存储在一个长整数中。我将 MouseButtons
值转换为长整数,发现按下左鼠标按钮时第 20 位被设置。同样,对于中间和右侧按钮,分别是第 22 位和第 23 位。为了帮助过程,我编写了一些函数,将整数值(byte
、short
、int
和 long
)转换为 string
,以便于查看和调试。
由于 >> 和 << 按位移位运算符返回整数值,因此这里的主要工作是将一个整数转换为 32 个 1 和 0 的字符串。
public static string intToString(int intIn)
{
string strRetVal = "";
for (int intBitCounter = 0; intBitCounter < 32; intBitCounter++)
{
char chr = intIn % 2 == 0
? '0'
: '1';
strRetVal = chr.ToString() + strRetVal;
intIn = intIn >> 1;
}
return strRetVal;
}
这很方便,我很高兴写了它。我相信很快它就会再次派上用场。
何必费心?
网上有几百种这个游戏的版本,那为什么还会有人自己写呢?
我曾经在手机上玩过一个类似的游戏版本(六边形棋盘),但对所有广告感到非常恼火。我太喜欢这个游戏了,甚至考虑支付 5.00 美元来去除广告,但后来我改变了主意,认为花一周时间写一个自己的版本会更划算……我确实按下了付费去除广告的选项,但它不起作用,并且极大地拖慢了我的无线网络,以至于当铃声响起时,它忘记了自己是一个电信设备,并且我在试图让它从一个缓慢的、充斥着广告的游戏恢复成手机时差点错过了一个电话。
那是一个错误的号码,这让我陷入了一个编码能量的恶性循环……而摆脱这种恶性循环的唯一方法就是编码,对吧?所以我就是这么做的。
我从上周日晚上 7 点开始,在午夜之前就开发了一个可用的游戏。获得如此快速的结果让我非常积极和鼓舞,所以我花了一周的时间来改进图形。
编码确实是消磨时间的好方法,但说实话……我只是讨厌广告。
历史
- 2020 年 9 月 28 日:初始版本
- 2020 年 10 月 2 日:软件更新 - 最终方块的第一个实例未消失
- 2020 年 10 月 4 日:添加了原始源代码 - 我在 5 小时内编写的。
- 2020 年 10 月 7 日:修复了高分文件保存 - 添加了计时器倒计时选项
- 2022 年 3 月 19 日:更改了得分方式,以激励一次收集更多方块
- 2023 年 6 月 5 日:添加了挑战
- 2024 年 4 月 13 日:更改了规则。当移除最终方块(4096 + 2048)时,玩家获得“免费生命”。
- 2024 年 8 月 8 日:调试了一个问题。用户通过高收益收集的组合会失败挑战。