EnigmaPuzzle






4.85/5 (52投票s)
Enigma Puzzle - 一个像魔方一样难的游戏。

引言
欢迎来到Enigma Puzzle——一款与魔方一样困难的游戏。
EnigmaPuzzle看起来无害,但却非常棘手。游戏区域由两个相交的圆形盘组成。每个盘上都有六个石块和六个骨块交替排列。石块看起来像肥胖的三角形,骨块则像瘦弱的长方形。
由于盘是相交的,它们共享两个石块和一个骨块。如果一个盘,比如说上面的盘,旋转60度,那么之前也属于下面盘的一个石块和一个骨块将被一个新的石块和一个新的骨块所取代。
游戏概念
Enigma的游戏概念很简单。如前所述,游戏区域由两个部分重叠的旋转盘组成。盘的周围有一个框架,该框架具有相邻盘组件的形状和颜色。
两个盘被分成小的部分(石块和骨块),这些部分可以有不同的颜色。石块和骨块进一步细分。这些部分也可以有不同的颜色。谜题的难度越高,使用的颜色就越多。
通过以随机方向交替旋转两个盘的倍数(60度),石块和骨块会变得混乱。
在游戏中,计算机随机地向左或向右旋转两个盘,以便各个部分位于其他位置。计算机应该转动多少次以及是否显示旋转可以在配置中指定。游戏的目标当然是通过转动盘来将其恢复到原始位置。可以通过单击箭头按钮或鼠标手势来转动它们。
下载中有一个详细的手册(EnigmaPuzzle.pdf)。英文版可以在\EnigmaPuzzle\en文件夹中找到。
背景
这个谜题由Douglas A. Engel发明,它包含一个塑料支架中的两个相交盘。这是对运行Windows操作系统的计算机的改编。
几年前,我在《Spektrum der Wissenschaft》(德国版《科学美国人》)的一期中首次看到这个谜题,我希望将其实现到计算机上。我的第一次尝试使用Turbo C失败了,因为性能太差。现在我再次尝试,程序应该可以在不太老的计算机上快速运行。这是我写的第一个游戏,因为我通常(用C++)为建筑行业开发。该程序是用Visual Studio 2010和C#开发的,并使用.NET 4.0。顺便说一句,它没有内置的解决方案算法(我不知道)。
有几个问题需要解决。首先,我必须能够将棋盘绘制到屏幕上。因此,我首先在纸上尝试,并使用了一些固定的坐标作为不同圆的中心。然后,我不得不进行大量的相交圆和线的计算,以确定一个石块和一个骨块的坐标。然后,我可以通过旋转和翻译来复制这些对象,以填充整个棋盘。框架只是辛苦的工作。
下一个挑战是确定所有当前石块和骨块的位置,以便判断谜题是否已解决。困难之处在于,当石块和骨块从上面的盘迁移到下面的盘或反之亦然时,它们的方向会发生变化。此外,通常有相同的石块或骨块,它们可能位于不同的位置。我通过一个盘上各个块位置的向量以及旋转盘时数组的一些复杂操作来解决这个问题。最复杂的部分是处理两个盘的交叉区域。详细解释它需要很多文字。了解它是如何工作的最好方法是调试代码并观察数组。
代码
程序的大部分相对简单,只有几个类。最重要的类是Block
、Figure
和Board
。
石块和骨块由不同的Block
对象组成。在棋盘上,有所有用于盘和框架的Figure
对象的数组。Board
对象也控制着整个游戏。
程序包含一些注释,所以代码应该很容易理解。
Block类
棋盘上的最小单元是Block
(例如,下方两张图片中的红色区域)。一个块由一个表示形状的GraphicsPath
、一个表示填充颜色的颜色代码和一个表示边框颜色的数字组成。Paint()
方法将块绘制到给定的Graphics
对象中。
public class Block
{
public GraphicsPath GP { get; set; }
public int Col { get; set; }
public int Edge { get; set; }
public Block()
{
GP = new GraphicsPath();
Col = -1;
Edge = -1;
}
public void Paint(Graphics g)
{
if (Col >= 0 && Col < m_colors.Count())
{
g.FillPath(m_colors[Col], GP);
}
if (Edge >= 0 && Edge < m_pens.Count())
{
g.DrawPath(m_pens[Edge], GP);
g.DrawPath(m_pens[1], GP);
}
}
}
Blocks
模块包含一些static
方法,这些方法可以创建所有必需的Block
对象,并用特定游戏级别的原始颜色填充它们。块的GraphicsPath
是使用计算出的坐标构建的,然后根据屏幕大小进行转换缩放。块在棋盘上始终是固定的,只有它们的颜色会根据当前图案进行设置。
源代码中只固定了其中一个石块和一个骨块的块坐标。所有其他块都是通过对给定块进行熟练的旋转和平移来创建的。这可以在Blocks
模块的static
方法Init(...)
中找到。
Figure类
此类构成了棋盘的石块和骨块。一个石块由三个块组成,这三个块围绕石块的中心旋转120度。下图显示了一个石块,它由一个红色、一个黄色和一个绿色块组成。
一个骨块由两个块组成,这两个块围绕中心翻转180度。下图显示了这样一个骨块。
除了块之外,Figure
对象还有一个表示对象方向的计数器。计数器指示Figure
相对于原始状态是否已旋转以及如何旋转。值为0(=原始)、1(旋转120度)或2(旋转240度)。当Figure
对象从上面的盘移动到下面的盘,或者反之亦然时,可能会发生方向的变化。
这样的对象当然必须能够自行绘制。这通过Paint()
方法完成,该方法仅执行所有添加的Block
对象的Paint()
方法。块的当前颜色由棋盘控制。
Board类
此类用于控制整个游戏。它提供了包含棋盘当前状态的成员和方法,并使得两个盘可以被旋转。每个盘由十二个Figure
对象(六个石块和六个骨块)组成。
如果游戏处于活动状态,??还会存储这些移动,以便游戏可以被保存和重新加载。
为了不必总是重绘所有内容,三个部分(上面的盘、下面的盘和框架)被绘制到位图。只有当发生变化时(例如,转动一个盘),才会创建并重绘此位图。框架当然只需要创建一次,因为它始终保持固定。
这里有一个创建上面盘位图的例子。该方法调用PaintDisc(...)
,后者使用其Paint()
方法绘制盘的所有Figure
对象。
private void CreateUpperdisc()
{
// Create a bitmap
if (m_upperdisc != null)
{
m_upperdisc.Dispose();
}
m_upperdisc = new Bitmap(m_w, m_h);
Graphics g = Graphics.FromImage(m_upperdisc);
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
// Paint the disc
Paintdisc(g, eDisc.eUpperDisc);
// Clean up
g.Dispose();
}
在Board
类中,您还可以找到用于旋转盘的方法。有实现图形旋转的方法(RotateDisc()
),还有一个方法保持逻辑状态始终最新(Rotate()
)。这个方法相当棘手,因为必须检查和处理所有可能的石块和骨块的转换。
一个重要的方法是GetColorString
。此方法提供一个string
,其中包含当前盘状态下棋盘上所有块的颜色索引。此string
可以与原始棋盘中的颜色string
进行比较。如果两个string
相等,则谜题已解决。这种相当复杂的过程是必需的,因为总是有多个解决方案,并且不能仅依赖于块的原始和当前位置。
public string GetColorString()
{
StringBuilder sb = new StringBuilder();
// First the stones and bones of the upper disc
for (int i = 0; i < 5; i++)
{
sb.Append(m_stones[m_upperStones[i]].GetColorString());
sb.Append(m_bones[m_upperBones[i + 1]].GetColorString());
}
sb.Append(m_stones[m_upperStones[5]].GetColorString());
sb.Append(m_bones[m_upperBones[0]].GetColorString());
// .. and now the lower disc
sb.Append(m_bones[m_lowerBones[1]].GetColorString());
for (int i = 2; i < 6; i++)
{
sb.Append(m_stones[m_lowerStones[i]].GetColorString());
sb.Append(m_bones[m_lowerBones[i]].GetColorString());
}
return sb.ToString();
}
数组“m_stones
”和“m_bones
”包含整个棋盘的Figure
对象。数组“m_upperStones
”和“m_upperBones
”包含上面盘特定位置上Figure
对象的索引。这些数组也将用于绘制盘(请参阅PaintDisc()
方法)。
使用块和图形
首先,Blocks
模块中的static
方法Init()
将创建游戏所需的所有Block
对象。这些Block
对象将被保存在一个static
数组“m_blocks
”中,并且在棋盘级别更改之前它们是固定的。
以下代码将通过首先创建一个块,然后旋转该块以获得其他两个块来创建一个石块。数组“m_blocks
”中的块0、1和2共同构成了第一个石块。
// Create the first sub-part of the first stone
m_blocks[0].GP.AddArc(new RectangleF(6.60254F, 20, 160, 160), 180, 21.31781F);
m_blocks[0].GP.AddArc(new RectangleF(-80, 70, 160, 160), 278.68219F, 21.31781F);
m_blocks[0].GP.AddLine(new PointF(40.00000F, 80.71797F), new PointF(28.86751F, 100));
m_blocks[0].GP.AddLine(new PointF(28.86751F, 100), new PointF(6.60254F, 100F));
Matrix mat120 = new Matrix();
mat120.RotateAt(120.0F, new PointF(28.86751F, 100));
// The second sub-part of the first stone (rotate the first by 120 degrees)
m_blocks[1].GP.AddPath(m_blocks[0].GP, false);
m_blocks[1].GP.Transform(mat120);
// The third sub-part of the first stone (rotate the second part by 120 degrees)
m_blocks[2].GP.AddPath(m_blocks[1].GP, false);
m_blocks[2].GP.Transform(mat120);
前四行将为“m_blocks
”数组中的第一个Block
对象创建GraphicsPath
,该对象由两个弧(A和B)和两条线(C和D)组成。如果绘制(不带字母),此块将如下所示。
为了创建第一个石块的另外两个块,该基础块绕石块中心旋转120度,而石块的中心是两条线的交叉点(上面图像中的M点)。
以类似的方式,第一个骨块的块将被创建并存储在“m_blocks
”的3号和4号位置。
// The first sub-part of the first bone
m_blocks[3].GP.AddArc(new RectangleF(6.60254F, 20, 160, 160), 218.68218F, -17.36437F);
m_blocks[3].GP.AddArc(new RectangleF(-80, 70, 160, 160), 278.68219F, 21.31781F);
m_blocks[3].GP.AddLine(new PointF(40.00000F, 80.71797F), new PointF(46.60254F, 69.28203F));
m_blocks[3].GP.AddArc(new RectangleF(6.60254F, -80, 160, 160), 120, 21.31781F);
Matrix mat180 = new Matrix();
mat180.RotateAt(180.0F, new PointF(43.30127F, 75F));
// The second sub-part of the first stone (rotate the first part by 180 degrees)
m_blocks[4].GP.AddPath(m_blocks[3].GP, false);
m_blocks[4].GP.Transform(mat180);
然后,这五个基本块将被复制六次,每次绕上面盘的中心顺时针旋转60度,以创建上面盘的所有块。下面盘的块是通过逆时针旋转上面盘的块绕下面盘中心创建的。在所有这些复制和旋转之后,框架还需要一些额外的块,然后棋盘的所有对象就准备好了。它们看起来会像下面这样。
在Board
类中,有一些数组保存着棋盘上的10个石块、11个骨块和18个框架部分。
/// <summary>
/// Figures in the board (stones and bones)
/// </summary />
private Figure[] m_stones = new Figure[10];
private Figure[] m_bones = new Figure[11];
private Figure[] m_frames = new Figure[18];
在InitBoard()
方法中,这些数组将被填充Figure
对象,以便可以绘制它们。Figure
对象与块链接以用于初始位置。数组“m_stones
”和“m_bones
”将用于创建盘的旋转图像。它们始终代表游戏的当前图形状态。
public void InitBoard(int level)
{
...
// Init the stones and bones
m_stones = new Figure[10];
m_bones = new Figure[11];
m_frames = new Figure[18];
// Build the blocks and color them
Block.Init(level);
// Build the stones and bones with the blocks
m_bones[0] = new Figure();
m_bones[0].AddBlock(28);
m_bones[0].AddBlock(29);
for (int i = 1; i < 6; i++)
{
m_bones[i] = new Figure();
m_bones[i].AddBlock(5 * (i - 1) + 3);
m_bones[i].AddBlock(5 * (i - 1) + 4);
}
for (int i = 0; i < 6; i++)
{
m_stones[i] = new Figure();
m_stones[i].AddBlock(5 * i);
m_stones[i].AddBlock(5 * i + 1);
m_stones[i].AddBlock(5 * i + 2);
}
for (int i = 6; i < 11; i++)
{
m_bones[i] = new Figure();
m_bones[i].AddBlock(5 * i);
m_bones[i].AddBlock(5 * i + 1);
}
for (int i = 6; i < 10; i++)
{
m_stones[i] = new Figure();
m_stones[i].AddBlock(5 * i + 2);
m_stones[i].AddBlock(5 * i + 3);
m_stones[i].AddBlock(5 * i + 4);
}
...
}
数组中的编号可以忽略,也可以是其他顺序。但这样,数组中的第一个骨块是正好位于两个圆交点左上方的那个,而数组中的其他石块和骨块则沿着一条线排列,就像您在棋盘上画一个数字8一样。
在InitBoard()
方法中还填充了一些其他数组(“m_upperBones
”、“m_upperStones
”、“m_lowerBones
”、“m_lowerStones
”)。这些数组保存着石块和骨块的当前逻辑位置,并用于检查谜题是否已解决。
深入了解绘制
有了上一段介绍的这些对象和数组,程序就可以绘制图形并通过鼠标手势来转动盘了。在主窗体(EnigmaPuzzleDlg
)的OnPaint()
方法中,只会将准备好的Bitmap
对象(“m_b.Background
”、“m_b.LowerDisk
”和“m_b.upperDisk
”)绘制到屏幕上。要看到正确的盘转动动画,哪个盘最后绘制非常重要。最后移动的盘总是最后绘制。
protected override void OnPaint(PaintEventArgs e)
{
...
e.Graphics.DrawImageUnscaled(m_b.Background, 0, 0);
if (m_b.RotDisk == Board.eDisc.eUpperDisc)
{
e.Graphics.DrawImageUnscaled(m_b.LowerDisk, 0, 0);
e.Graphics.DrawImageUnscaled(m_b.UpperDisk, 0, 0);
}
else
{
e.Graphics.DrawImageUnscaled(m_b.UpperDisk, 0, 0);
e.Graphics.DrawImageUnscaled(m_b.LowerDisk, 0, 0);
}
}
但在位图可以使用之前,它们必须被创建。这在CreateBackground()
、CreateUppderDisk()
和CreateLowerDisk()
方法中完成。这些函数的第一次调用通常来自OnResize()
方法,因为程序总是会填充整个屏幕。之后,更新Bitmaps
的调用将由旋转函数RotateDisk()
和处理所有旋转的Rotate()
方法(图形和逻辑)启动。
public bool Rotate(eDisc disc, eDirection dir, bool bShow, EnigmaPuzzleDlg form)
{
...
// Graphically rotation
RotateDisk(60.0f, disc, dir, bShow, form);
// Logically rotation - adjust the stones and bones on the disks and take incount
// that there are two stone and one bone that overlap
...
// Create the bitmaps newly and show them
if (bShow)
{
CreateUpperDisk();
CreateLowerDisk();
form.Refresh();
}
// Check if the game has been solved
if (GameActive)
{
...
}
return false;
}
无论何时用户开始游戏,或者当一个盘应该作为对输入的响应而转动时,都会调用Rotate()
方法。然后,该方法调用RotateDisk()
方法,后者又调用另一个更复杂的RotateDisk()
方法来显示转动盘的动画。这两个函数看起来相当复杂,但这仅仅是因为处理摆动盘的动画。也可以没有第二个RotateDisk()
函数来完成。
Rotate()
的一个重要部分是处理逻辑状态。这并不像看起来那么复杂,但必须处理所有可能的旋转。在Rotate()
的最后,可能会重新创建位图,并且会检查上一次转动是否已解决谜题。
处理鼠标手势
可以通过单击带有箭头的四个小按钮之一来转动盘。但也可以通过鼠标手势来完成。主窗体的MouseDown
和MouseUp
事件用于获取两个点的坐标。下图显示了两种可能的手势——从A到B,以及从C到D。AB应该导致上面盘顺时针转动,CD应该使下面盘顺时针转动。
Board
类中的TurnDisk()
函数处理两个给定点“(x1,y1)”和“(x2,y2)”的坐标,并确定要转动的盘和转动方向。例如,上图中有点A和B,它们的坐标可能是(ax,ay)和(bx,by)。
首先在TurnDisk()
中检查哪个盘应该被转动。这是以一种非常简单的方式完成的。任何起始点在棋盘中线(上图中的M)之上的移动都属于上面盘,而中线下方的所有移动都属于下面盘。垂直起始点是“y1
”。这段代码可以在TurnDisc()
的前几行找到。要转动的盘也定义了转动的垂直中心“cy
”。
之后,会对两个给定点的坐标进行一些移位。坐标被移动,使得要转动的盘的中心位于坐标(0,0)。这里使用变量“cy
”来计算垂直移位。在同一操作中,屏幕坐标(从上到下)被转换为真实坐标(从下到上)。
下一步是检查移动是否足够长。单击(鼠标按下-鼠标释放)或非常短的拖动不应转动盘。移动的长度可以计算为两个点“(x1,y1)”和“(x2,y2)”之间的向量长度。预计最小长度为20像素。
public bool TurnDisk(float x1, float y1, float x2, float y2, EnigmaPuzzleDlg form)
{
eDisc disc = eDisc.eUpperDisc;
eDirection dir = eDirection.eLeft;
float cy;
// Determine the disk - just look at the hor. middle of the board
if (y1 < MiddleY)
{
disc = eDisc.eUpperDisc;
cy = m_upperCenter.Y;
}
else
{
disc = eDisc.eLowerDisc;
cy = m_lowerCenter.Y;
}
// Move the coordinates so that the y-axle is in the middle of the board
x1 -= MiddleX;
x2 -= MiddleX;
// Because 0/0 is upper left corner, we have to inverse the y-coordinate
y1 = -(y1 - cy);
y2 = -(y2 - cy);
// If the drag is too short (length of the turning vector) - do nothing
// Get the turning vector
float vx = x2 - x1;
float vy = y2 - y1;
if (Math.Sqrt(vx * vx + vy * vy) < 20)
{
return false;
}
// Calc vector product to get the orientation
double orient = x1 * y2 - y1 * x2;
if (orient > 0)
{
dir = eDirection.eLeft;
}
else
{
dir = eDirection.eRight;
}
// Do the rotations
Rotate(disc, dir, true, form);
return true;
}
TurnDisk()
中的最后一个动作是确定转动的方向。有一个非常简单的方法可以做到这一点。需要的是两个向量“(x1,y1)”和“(x2,y2)”的向量积。如果该值大于零,则盘必须逆时针转动,否则顺时针转动。
关注点
对我来说,几乎所有东西都很有趣,因为我从未接触过GraphicsPath
、Bitmap
以及使用Matrix
类进行转换。由于我以前从未做过游戏编程,我相信有很多东西可以做得更好或更容易。
多种语言
该程序支持两种语言——德语和英语。在Visual Studio解决方案中,窗体的默认语言是德语。在运行时,将检查计算机的区域设置。如果与“德语”(CH、DE、AUT)有关,则程序将以德语用户界面启动;否则,将以英语启动。
安装
安装程序是用Inno-Setup编译器创建的。安装程序的源文件可以在顶层文件夹(EnigmaPuzzle.iss)中找到。
历史
- 版本1.0 - 2011年11月11日
- 版本1.1 - 2011年11月17日 - 添加了从全屏切换到可调整大小窗口的设置