生命游戏模拟器作为学习工具
通过实现一个生命游戏模拟器,我学到了更多关于 C#、UserControl 和项目设计的知识。

引言
由于我只使用了 C# 几个月,我想学习更多。我决定创建一个需要各种开发领域知识的项目,这样我就被迫学习新东西。我一直觉得生命游戏 (GoL) 的概念很有趣,于是我决定用 C# 实现我自己的模拟器。
当然,我的第一步是搜索 Code Project,看看已经有什么了。我 找到了 几个 文章。当然,这些都不能完全符合我想要创建的内容 - 但它们有助于我获得灵感。
背景
如果你以前从未听说过,这里是 维基百科对康威生命游戏的定义
生命游戏,也简称为生命,是由英国数学家约翰·霍顿·康威于 1970 年提出的一个细胞自动机。"游戏"是一个零玩家游戏,意味着它的演变由其初始状态决定,不需要进一步的人类输入。通过创建初始配置并观察其演变来与生命游戏互动。
基本上,你有一个由“存活”或“死亡”细胞组成的网格。所使用的规则集决定了影响细胞状态的邻居数量。
例如,康威的规则是 23/3,斜杠前的数字是生存规则,斜杠后的数字是诞生规则。在这种情况下,如果一个存活的细胞有两个或三个邻居,它将存活 - 如果它有更多或更少,它将死亡。如果一个死亡的细胞正好有 3 个邻居,它就会诞生(变得存活)。
项目需求
虽然我想直接开始编码看看我能想出什么,但我首先考虑了项目需求。我知道我想要一个独立于 UI 的模拟引擎。我知道我至少会有两个不同的 UI(CLI 和 WinForms)。我知道我想要为 WinForms GUI 中的网格显示创建一个自定义控件。
这三个目标(分离数据和 UI、创建自定义控件和创建两个 UI)是推动我从这个项目中学习的因素。
GoL 模拟引擎
我的第一个决定是让游戏网格成为一个有限字段 - 这大大简化了算法设计。一旦做出这个决定,其余的就很快就到位了。
要运行 GoL 模拟,我们需要一个可以存在于两种状态的细胞网格的表示。我选择使用一维 `bool` 数组来实现最佳速度和人类可读性。由于我们需要一个数组来表示当前和下一代,所以我们实际上有两个细胞状态数组。我还添加了第三个用于存储初始状态,以便可以重新开始游戏。
GoL 模拟的下一个要求是从一代到下一代的算法。由于 GoL 模拟有各种规则集,我想使用可变规则。知道细胞、列和行的数量也很好,所以我们在 `LifeGame` 类中有以下字段。
#region Fields
private bool[] _currentStates;
private bool[] _newStates;
private bool[] _startStates;
private int _rows;
private int _cols;
private int _cells;
private int _liveCells;
private List<int> _surviveRules;
private List<int> _birthRules;
private string _unparsedRules;
#endregion
由于我使用了 `int` 的泛型列表作为规则,因此向前推进一代的方法相当简短。
/// <summary>
/// Advances the entire population by one generation.
/// </summary>
private void advancePopulation()
{
if (_liveCells == 0)
{ return; }
_liveCells = 0;
int neighbors;
int index;
bool alive;
for (int y = 0; y < _rows; y++)
{
for (int x = 0; x < _cols; x++)
{
neighbors = getNeighbors(x, y);
index = x + y * _cols;
alive = _currentStates[index];
// If cell is alive and survives, or dead and is born
if ((alive && _surviveRules.Contains(neighbors)) ||
(!alive && _birthRules.Contains(neighbors)))
{
_newStates[index] = true;
_liveCells += 1;
}
else // The cell neither survives nor is born
{ _newStates[index] = false; }
}
}
}
正如你所看到的,通过将坐标转换为一维数组 `(x + y * _cols)` 中的索引来访问网格中 `x`,`y` 坐标的状态。
此外,`advancePopulation` 只在 `_newStates` 数组中创建下一代。将该数组复制到 `_currentStates` 数组的操作是在向前推进一代(`LifeGame.Step()`)的公共方法调用中处理的。
现在,我知道我提到了我想要将 UI 与数据端分开,但这只是为了实现命令行输出。我简单地创建了一个 `ToString` 方法的重写,然后 voilà - 即时 CLI 显示。
/// <summary>
/// Converts the grid to a string representation.
/// For 'dead' cells uses ".", for 'alive' cells uses "*".
/// </summary>
/// <returns>String representation of the grid.</returns>
public override string ToString()
{
StringBuilder sb = new StringBuilder();
for (int y = 0; y < _rows; y++)
{
for (int x = 0; x < _cols; x++)
{
sb.Append(_currentStates[x + y * _cols] ? '*' : '.');
}
sb.AppendLine();
}
return sb.ToString();
}
命令行界面
由于 `LifeGame` 类内置了 `ToString` 重写,创建 CLI 界面相当简单。这很好,因为它提供了一个快速的测试平台来验证模拟是否准确地遵循了规则。
这是完整的命令行程序。
static void Main(string[] args)
{
LifeGame lifeGame = new LifeGame(60, 40); // 60x40 looks fairly square
double lifeProbability = 0.25;
lifeGame.Randomize(lifeProbability);
bool exit = false;
bool first = true;
int genCount = 0;
while (!exit)
{
Console.Clear();
if (!first)
{
lifeGame.Step();
genCount += 1;
}
first = false;
Console.Write(lifeGame.ToString());
Console.Write("Any key to continue, 'r' to randomize, 'q' to quit. Gen: " + genCount);
char key = Console.ReadKey(false).KeyChar;
if (key == 'q')
{
exit = true;
}
else if (key == 'r')
{
lifeGame.Randomize(lifeProbability);
first = true;
genCount = 0;
}
}
}
很简单,不是吗?需要注意的关键部分是创建一个具有 60x40 网格(2,400 个单元格)的 `LifeGame` 以及用于随机化单元格的方法。当你调用 `Randomize` 方法时,你会提供一个 `double`,表示一个细胞存活的概率。在这种情况下,大约 25% 的细胞将开始作为存活细胞。
运行时看起来像这样(注意右下角的 滑翔机)

WinForms 界面
这是我花费大部分开发时间的地方,尽管我认为代码中没有什么特别值得注意的地方。如果你注意到任何值得更详细介绍的内容,请告诉我,我会更新文章。
我想要一个干净的 UI,它允许对 GoL 模拟进行大量自定义,并且能够加载和保存生命模式文件(保存仍是待办事项)。由于 `LifeGame` 类的设计,我们可以在任何时候更改规则集 - 即使在模拟运行时。因此,我创建了一个带有各种流行规则集的 `combobox`,这些规则在选择后会被应用。当一个稳定模式的规则集发生改变时,看到这些变化很有趣。
正如你在本文开头截图中看到的,我想要相当多的控制,而无需深入挖掘。所有主要功能都由网格下方的按钮控制。这里还显示了当前代数和人口数量。
菜单项允许打开和保存(即将推出!)生命模式文件(`File`),以及自定义网格显示(`Options`)以及有关最新加载模式和应用程序的一些信息(`Help`)。
一个有趣的点是能够单击网格控件来更改网格中单元格的状态。这是通过网格控件的 `MouseDown` 事件处理程序实现的。
void lifeGrid_MouseDown(object sender, MouseEventArgs e)
{
int y = (int)(((float)e.Y) * _lifeGame.Rows / lifeGrid.Height);
int x = (int)(((float)e.X) * _lifeGame.Columns / lifeGrid.Width);
_lifeGame.ToggleCellState(x, y);
lifeGrid.UpdateGrid(_lifeGame.GameGrid);
}
网格本身是一个自定义控件,允许进行相当多的自定义。在任何时候,你都可以显示/隐藏网格线,更改“存活”单元格的颜色,以及更改“死亡”单元格的颜色。自定义网格包含在一个面板中,允许通过简单地增加自定义网格的大小来实现缩放(然后在面板中滚动)。由于网格设计为绘制成任何设置的大小,这起到了缩放的作用。
UserControl LifeGrid
我想创建一个 `UserControl`,因为我以前从未做过。我在网上某个地方找到了一个网格控件(如果我能再次找到它,我会注明来源 - 如果你认出它,请告诉我!),它给了我一个很好的起点。`Paint` 事件处理程序在很大程度上源于他们的工作。
在学习了一些关于自定义属性的知识后,我为这个控件实现了一些。这就是允许在运行时更改单元格颜色和网格线可见性的原因。这里是如何设置网格可见性:
/// <summary>
/// Gets or Sets whether grid lines are visible.
/// </summary>
[Category("LifeGrid"),
Description("Whether or not lines are displayed."),
DefaultValue(true)]
public bool LinesVisible
{
get { return _gridLinesVisible; }
set { _gridLinesVisible = value; Invalidate(); }
}
请注意设置可见性时的 `Invalidate` 调用。这可以确保控件在每次更改此属性时(包括设计时)都会被重绘。
这个控件的主要部分当然是网格的绘制。当调用 `UpdateGrid` 时,它会期望(并检查)一个大小与网格控件匹配的 `bool` 数组。然后将此数组复制到一个内部字段,供 `Paint` 事件处理程序使用。
private void LifeGrid_Paint(object sender, PaintEventArgs e)
{
float cellWidth = (float)Width / _cols;
float cellHeight = (float)Height / _rows;
float line = 0;
if (_gridLinesVisible) { line = _gridLineThickness; }
Graphics painter = e.Graphics;
SolidBrush aliveBrush = new SolidBrush(_cellColorAlive);
SolidBrush deadBrush = new SolidBrush(_cellColorDead);
// Clear the control
painter.FillRectangle(new SolidBrush(BackColor), new Rectangle(0, 0, Width, Height));
for (int y = 0; y < _rows; y++)
{
for (int x = 0; x < _cols; x++)
{
if (_gridStates[x + y * _cols])
{
painter.FillRectangle(aliveBrush, x * cellWidth, y * cellHeight,
cellWidth - line, cellHeight - line);
}
else
{
painter.FillRectangle(deadBrush, x * cellWidth, y * cellHeight,
cellWidth - line, cellHeight - line);
}
}
}
}
我尽力做到最好,但它仍然是 GUI 应用程序中的瓶颈。在没有显示的情况下运行,模拟器可以在 10 秒内在一个 250x250 的网格(62,500 个单元格)上工作 1,000 代。但是,当显示时(即使在显示更新之间跳过几代),达到 1,000 代标记大约需要一分钟。
已知错误/计划改进
我现在唯一知道的错误是放大游戏网格并单击它。应该发生的是,单击的单元格会被翻转(是的,可以工作),并且显示会保持在原位(不行,它会重置包含我们自定义控件的面板)。
计划的改进包括修复那个该死的错误,可能从使用计时器转向多线程模型,以及实现保存功能(最好有多于一种保存类型)。
我还想更多地了解控件的绘制,看看我是否能改进网格中的瓶颈。
最终想法
整个项目对我来说都是一次学习经历,我希望其他人也能从中学习到一些东西。话虽如此,我还没有从中学到全部。
请告诉我你对文章和代码的看法。如果对两者有任何改进的建议,我很乐意听取建设性的批评!
历史
- 2009 年 9 月 9 日 - 首次发布
- 2009 年 9 月 10 日 - 小幅增补和拼写/语法修正