这是一篇基于本项目实际代码的技术笔记:从细胞规则出发,依次看状态更新、稀疏计算、交互状态机,以及并行和递归能带来什么。

图 1:本项目的实际界面。用户先摆放细胞,再确认布局并开始演化。
1. 一格细胞,只需要两个状态
每个格子不是活,就是死。它下一代的状态,只由自己和周围 8 个邻居决定。

| 当前状态 | 活邻居数 | 下一代 |
|---|---|---|
| 活 | 2 或 3 | 活 |
| 活 | 0、1,或者 4 及以上 | 死 |
| 死 | 恰好 3 | 活 |
| 死 | 其他数量 | 死 |
这四条规则常写作 B3/S23 :B 表示诞生,S 表示存活。
把表格翻译成一个判断,便得到了项目里的核心条件:
js
// count 是活邻居数,living 是当前代的活细胞集合
const aliveNext = count === 3 || (count === 2 && living.has(cell));
当邻居数为 3 时,活细胞存活、死细胞新生;当邻居数为 2 时,只有原本活着的细胞能留下。
2. 「同时更新」不等于「同时开很多线程」
生命游戏的关键约束是:计算下一代时,每个格子都只能看到同一份旧状态。

图 2:三个横向活细胞会变成竖向;再前进一步,又变回横向。这种图案叫闪烁器。
如果算完一个格子就立刻改写原网格,后面的格子可能读到已经更新过的邻居。此时结果会依赖扫描顺序,规则也就变了。
本项目采用两份状态:
text
只读:第 t 代 living
计算:所有候选格的邻居数
写入:全新的 next
完成:返回 next,作为第 t + 1 代
计算过程可以按顺序执行;只要始终读旧代、统一发布新代,规则上就是同时更新。
3. 不扫描整张地图:只统计活细胞附近

地图里大部分时候是空白。项目只保存活细胞坐标,例如:
js
const living = new Set(['1,0', '2,1', '0,2']);
每个活细胞向周围 8 个坐标各贡献一次计数。随后,对计数表里的每个坐标应用规则:
js
// 节选自 src/life.js
const key = `${x + dx},${y + dy}`;
neighbours.set(key, (neighbours.get(key) ?? 0) + 1);
// 完成所有计数后,写入新的集合
if (count === 3 || (count === 2 && living.has(cell))) {
next.add(cell);
}
这里有两个容易忽略的细节:
- 零邻居的活细胞不会出现在计数表中,因此不会进入下一代,正好对应孤独死亡。
- 计数表包含活格和死格:死格若收到 3 次计数,也有机会诞生。
设当前有 M 个活细胞,每个细胞固定检查 8 个方向。假设坐标长度和哈希操作成本可视为固定,单代计算的平均时间与额外空间都可记为 O(M) ;候选坐标不超过 8M。
这适合稀疏世界。若画面长期非常密集,字符串和 Map 的额外开销可能让二维数组更有优势,不能简单说稀疏存储在所有情况下都更快。
4. 按钮背后,也是一个小状态机

这是对交互行为的概括;代码里使用 editing、running 两个布尔值表达这些状态,并没有额外定义一个状态机类。
| 状态 | 能做什么 | 如何离开 |
|---|---|---|
| 编辑布局 | 点击格子添加或移除细胞 | 点击"确认布局" |
| 已确认 / 暂停 | 手动下一代,或开始自动运行 | 点击"重新编辑"或"自动运行" |
| 自动运行 | 定时前进一步 | 点击"暂停运行"或"重新编辑" |
"清空重来"会停止计时、清空活细胞、将代数归零并回到编辑状态。切换地图尺寸不会清空已有细胞。
5. 展示区固定,改变的是视野密度

这里的 30×30、60×60、100×100 是当前可见区域的格子数量,不是世界的硬边界。
| 显示区域宽度 | 可见格数 | 每格宽度 |
|---|---|---|
P 像素 |
30 格 | P / 30 |
P 像素 |
60 格 | P / 60 |
P 像素 |
100 格 | P / 100 |
例如展示区宽 600 像素时,三种格宽分别是 20、10、6 像素。窗口尺寸不变时,切换地图规模只改变格子密度。
计算使用世界坐标,绘制时再减去视野起点:
text
视野起点 = 视野中心 - floor(可见格数 / 2)
可见列号 = 世界 x 坐标 - 视野起点 x
细胞可以在当前画面外继续演化;拖动画布可以把它们带回视野。这里的"无限"指逻辑上没有固定地图边界,实际运行仍受内存与 JavaScript 数值范围限制。
6. 如果需要并行计算,该怎么拆?

当前版本没有使用 Web Worker,也没有并行计算。 图 6 只是规模变大时可以考虑的扩展方案。
一种方案是把旧代的活细胞分组,各个 Worker 分别生成局部邻居计数;所有结果返回后,将相同坐标的计数相加,再统一应用规则。
有两条边界不能漏掉:
- 跨分组的邻居必须合并。 一个格子的邻居可能来自不同分组,不能凭局部计数就决定生死。
- 旧任务不能覆盖新操作。 如果用户在计算途中清空或重新编辑,应取消旧任务或用版本号丢弃过期结果。
Worker 可以把计算移出浏览器主线程,但会引入数据传递和合并成本;小地图不一定更快。MDN:Web Workers
7. 逐代递推,是不是递归算法?
生命游戏有一个简洁的递推关系:
text
第 1 代 = F(第 0 代)
第 2 代 = F(第 1 代)
第 t+1 代 = F(第 t 代)
这里的 F 就是"统计邻居,再按 B3/S23 生成下一代"。
但本项目的实现不是递归函数 :手动点击或定时器触发一次计算,得到新集合后等待下一次触发。用循环或事件反复应用 F,同样能够实现递推。
真正值得讨论递归的例子是 HashLife:它把区域组织成可复用的层级结构,并缓存重复区域的演化结果;对于时间或空间上高度重复的图案,可以大幅减少重复工作。
这是一种不同的实现路线,本项目没有实现 HashLife 。它需要处理区域重叠、边界与缓存管理,对当前交互式小规模需求并非必要。Gosper 原论文;Golly 的 HashLife 说明。
8. 实测:计算一代要多久?

**测试场景:**将方形区域全部填满活细胞,每次从同一份初始状态计算一代;预热 10 次,采样 40 次。
| 初始填满区域 | 初始活细胞 | 下一代活细胞 | 中位数 | P90 |
|---|---|---|---|---|
| 30×30 | 900 | 116 | 0.54 ms | 0.61 ms |
| 60×60 | 3,600 | 236 | 2.06 ms | 2.18 ms |
| 100×100 | 10,000 | 396 | 5.93 ms | 6.34 ms |
环境:2026-11-06,Apple M2,macOS,Node.js v25.8.1。数据来自项目中的 nextGeneration,不包含浏览器绘图、点击响应、打包加载或内存测试,也不代表其他设备的速度。
在项目源码目录下可重新运行:
bash
node tests/benchmark.mjs
查看本次原始采样数据。测试使用同一初始状态重复计算,并非连续演化 40 代;数值也会随机器、系统负载和运行环境变化。
9. 验证时,优先看什么?
项目的 tests/life.test.mjs 用了五个有代表性的检查:
| 检查 | 验证目的 |
|---|---|
| 孤立细胞 | 0 个邻居时死亡 |
| 四格方块 | 稳定图形保持不变 |
| 闪烁器 | 同时更新,且两代后回到原状 |
| 拥挤中心 | 邻居达到 4 个及以上时死亡 |
| 跨原点滑翔机 | 允许负坐标,并能跨越视野方向继续演化 |
运行方式:
bash
node --test tests/life.test.mjs
自动运行使用 setInterval(..., 3000);浏览器忙碌或页面处于后台时,实际间隔可能长于 3 秒,并非精密时钟。MDN:setInterval
最后的三个工程取舍
- 先保证规则正确。 新旧状态分离,比是否使用多线程更基础。
- 按真实数据规模选择结构。 稀疏
Set和Map实现简单;遇到密集大图再比较数组、分块或 Worker。 - 把世界和视野分开。 可见网格的大小属于显示设置,细胞是否存在属于模拟状态。
项目运行入口是自包含 HTML;文章中的源码路径相对于本地项目目录。源码、文章与配图保留在项目文件夹中,游戏压缩包只包含运行入口和使用说明。