最近我忙里偷闲,总琢磨一些看起来很复杂,但核心规则又异常简单的东西。前几天,我在咖啡馆等我儿子放学,百无聊赖,旁边几个大学生在讨论什么“元胞自动机”,听得我一愣一愣的。我就想,这玩意儿真有那么玄乎吗?是不是跟我们平时搞的那些高大上的项目一样,一层层包装下来,底层就是几个简单的“如果-那么”判断?
越想越手痒,我就决定自己找个最基础的练练手。我选了那个老掉牙的《生命体游戏介绍》,也就是康威的生命游戏(Conway's Game of Life)。这名字听着挺高大上,就是在一个二维格子里,定义几个简单的生生死死的规则,然后让它自己跑起来看能跑出个什么花样。
动手准备:确定工具和画布
既然要跑起来,肯定得有个能画画的地方。我没搞那些复杂的后端框架,毕竟我只是想看看效果。我就直接开了一个HTML文件,准备用最原始的JavaScript加上Canvas来画图。这玩意儿简单,开箱即用,不用管什么环境配置,省心。
我的第一步就是搭架子。我定义了一个固定的网格大小,比如100x100。我初始化了一个二维数组,这个数组就是我的世界。数组里的每一个位置,我都给它赋值了两个状态:0代表死亡(白色),1代表存活(黑色)。
这个初始化过程,我开始是全随机的,但跑了几次发现初始状态太散乱,跑不了几代就全灭了。于是我调整了初始设置,只随机生成大约30%的活细胞,这样“世界”才更容易活下来。
核心逻辑:掰扯那四条规则
生命体游戏的核心就在于那四条简单的规则,我得把它翻译成代码能懂的语言。这部分我花的时间最长,主要是绕晕在如何正确地计算每个细胞的“邻居”数量上。
我写了一个独立的函数,专门用来计算某个坐标周围八个方向上的活细胞数量。这是最容易出错的地方,因为要处理边界情况——比如最左上角的细胞,它只有三个邻居,而不是八个。我用了一堆取模和判断语句来确保不会跑到网格外,防止程序报错。为了保险,我特意在计算邻居时,把“当前细胞”的状态排除了,只数周围的。
然后,我开始执行规则判断:
- 规则一:孤独致死。如果一个活细胞周围的活邻居少于两个,那么它下一代就得死(孤独死了)。
- 规则二:舒适生存。如果活细胞周围有两到三个活邻居,那么它下一代继续活蹦乱跳。
- 规则三:过度拥挤。如果活细胞周围的活邻居多于三个,它下一代也得死(挤死了)。
- 规则四:繁殖奇迹。如果一个死细胞周围正好有三个活邻居,那么它下一代就活过来了(新生命诞生了)。
我发现一个大坑:更新状态不能原地更新。如果你计算当前状态时,直接修改了数组,那么后面的细胞计算就会用到已经更新的“未来”状态,结果跑出来的模式根本不对。我必须创建第二个临时的二维数组(我叫它“下一代”),所有的新状态都写到这个临时数组里,等到一轮计算完全结束后,才把“下一代”赋值给“当前世界”。这才把逻辑理顺。
可视化与迭代:让它动起来
光有计算还不行,得让它跑起来才好玩。我动用了Canvas的绘图功能,把我的100x100的数组内容,一格一格地画在屏幕上。活细胞就填充成黑色小方块,死细胞就留白。
为了让它连续播放,我设置了一个定时器(用了requestAnimationFrame,因为听说这个效率高,不占用资源),让整个“计算——绘图——赋值”的流程每秒钟执行十几次。刚开始,我设得太快了,画面闪得眼睛疼,我就手动调慢了速度,确保能看清每个阶段的演化。
我最喜欢干的一件事,就是尝试各种“种子”形状。我试着画了几个著名的图形:
- “滑翔机”(Glider):这个最经典,跑起来像个小虫子,一直沿着对角线移动。我盯着它跑了十几分钟,发现它竟然能稳定地自我复制和移动,特别有意思。
- “脉冲星”(Pulsar):这个形状复杂一点,它每三代就周期性地闪烁一次,像个心脏在跳动,壮观。
看着屏幕上那些简单的黑白小格子,根据四个简单到不能再简单的规则,自己演化出了移动、闪烁、甚至复杂的周期性结构,我突然就明白了那些大学生在讨论什么了。
很多复杂系统的背后,规则超级简单。我们平时在工作里,总觉得要用复杂的架构和工具去解决问题,但这个生命游戏教会我:定义清晰、简单的底层规则,让它们自己去碰撞和迭代,反而能生出意想不到的复杂和活力。
等我儿子放学回来,看到我在那里看一堆黑白格子跳舞,他问我在干什么。我没给他讲什么元胞自动机,我就跟他说,我在玩一个“世界模拟器”。他看完也觉得挺神奇,非要我给他画个大大的正方形看它能不能活下来。结果不出所料,正方形中间会死掉,只留下边缘几圈,又变回了简单图案。
这回实践虽然简单,但让我重新审视了“复杂”这个词。实践的记录我都放本地了,随时可以拿出来跑一跑,下次准备试试三维版本,看看还能不能跑出点新花样。