生命游戏开发记录

与网络上其他版本不同的是,我使用了彩色格子。完善Airglass.js的继承机制,增加了extend方法,优化了Renderable和Effect两个基础可渲染类。采用常见规则借助Airglass.js实现出了生命游戏,进一步加深了我对面向对象编程思维的理解与应用熟练度。

这是生命游戏(元胞自动机)体验入口

生命游戏动图演示

3+1层渲染器

首先初始化一个Airglass实例,参数是包含DOM容器元素、宽度、高度与设备像素比属性的对象。容器元素必须是纯DOM,且是必填项。宽度与高度为可选项,默认宽度为300,高度为150,这也是canvas画布在不设置宽、高时的默认值。设备像素比就是window对象的devicePixelRatio的值,也是可选参数,在初始化Airglass时,默认设置为devicePixelRatio。

let ag = new airglass.Airglass({
  element: document.getElementById('wrap'),
  width: 900,
  height: 600,
  DPR: window.devicePixelRatio
});

完成生命游戏,我主要使用了Airglass中的三层渲染器Glass。最底层用来渲染格子。中间一层渲染用于控制运行的开始与暂停的开关按钮。最上层渲染用于处理清空渲染格子的渲染器和重置所有格子状态。另外再加一层Glass用于可视化选定的格子。

let mainrenderer = ag.addRenderer('main');
let playcontrollerrenderer = ag.addRenderer('playcontroller');
let clearbuttonrenderer = ag.addRenderer('clearbutton');
let selectrenderer = ag.addRenderer('select');

格子间里的神仙

我已经越来越喜欢在自己的代码中加入面向对象思维的思考方式了。我认为的面向对象思维是把程序的逻辑拟人化。在实现生命游戏的过程中,我又采用了拟人化思维从始至终地完成了创作。普遍的拟人化理解是将格子间理解为人,将相邻八个格子间作为变数制造孤独与资源匮乏的影响,进而影响位于中间的人的生死。不过我想换一个故事。

请想象在一个由计算机支配的电子虚拟世界里,直线纵横交错出整齐排列的格子,格子默认关闭。格子间里住着一位身穿彩衣的神仙,格子只有在打开时才能看到住在里面的神仙。每一个格子间都遵循以下规则。与其称作规则,不如叫做故事:

  • 相邻八个格子间的神仙能召唤出中间格子里的神仙,但需要恰当的能量;
  • 一位神仙的能量不足以召唤出神仙;
  • 两位神仙的能量让中间格子恰好保持原样;
  • 三位神仙的能量可召唤出中间格子的神仙;
  • 四位及以上的神仙产生的能量太大,会让中间的格子关闭。

有了故事,剩下的就是演员登台演绎故事了。一种叫做格子的道具,一类叫做神仙的身穿彩衣的不明物体。接下来提取两个种类的事物的特征与行为,并呈现。如果每一个格子代表一个像素点,那么足够多的格子也可以拼出复杂的图像。

创造格子间与神仙

Airglass提供两个描述可渲染物体的类——Renderable和Effect。Renderable能直接添加到场景中并被渲染器渲染,Effect继承了Renderable,同时增加了操作关键帧的能力。

Renderable和Effect两个抽象类并不会作为构造函数直接创建实例,他们常常作为父类使用,在其原型链上追加新的特征和功能。Airglass暴露出名为extend的工具纯函数,专门用来实现类的继承并扩展类的特征和功能。该函数会返回一个新的继承了Renderable且prototype属性包含自定义方法的类。这样使用extend函数:

let Block = airglass.extend(airglass.Renderable, {
  _constructor: function (params) {
    this.path;
    this.drawPath;
    this.x = params.x || 0;
    this.y = params.y || 0;
    this.size = params.size || 1;
    this.padding = params.padding || 0;
    this.isLive = params.isLive || (function () {
      let a = [1, 0];
      return !!a[Math.floor(Math.random() * a.length)];
    })();
    this.fill = params.fill || `hsl(${Math.ceil(Math.random() * 360)}, 50%, 50%)`;

    this._x = this.x + this.padding;
    this._y = this.y + this.padding;
    this._size = this.size - this.padding * 2;
  },
  updatePath: function () {
    this.path = new Path2D();
    this.path.rect(this.x, this.y, this.size, this.size);

    this.drawPath = new Path2D();
    this.drawPath.rect(this._x, this._y, this._size, this._size);
  },
  draw: function (ctx) {
    if (this.isSelected) {
      ctx.strokeStyle = '#fff';
      ctx.stroke(this.drawPath);
    }
    if (this.isLive) {
      ctx.fillStyle = this.fill;
      ctx.fill(this.drawPath);
    }
  }
})

创建好继承自Renderable类的格子间,格子间用来存储状态,还需要初始化条件。比如那些格子间一开始就是打开的状态,而另一些是关闭的状态。还有和绘制有关的不做赘述。

生活在二维世界的格子里的生物

最终呈现在屏幕上的就是根据规则条件不断在一轮又一轮回合中改变格子间开启与闭合状态的效果。第一章中提到的五条规则就是实现这一效果的关键,改变规则就能改变效果,我采用的是最普遍应用的规则,只是在上一章换了一种表述方式。就最普遍的规则而言,相邻八个格子间是决定中间格子间状态的关键,所以下面介绍如何在每一个格子中存储相邻八个格子的状态。

格子间初始化

格子间的初始化涉及到两方面,一方面是对格子间初始状态的设定,主要是开启和关闭。另一方面是初始化对相邻八个格子的引用关系,这将方便在每个回合后统计相邻八个格子间的状态并更新中间的格子间的状态。

随机的0和1

// 初始化
let blockSize = 8 * ag.DPR;
let colNum = Math.floor((ag.bounds.width - 60) * ag.DPR / blockSize);
let rowNum = Math.floor(ag.bounds.height * ag.DPR / blockSize);
for (let row = 0; row < rowNum; row++) {
  for (let col = 0; col < colNum; col++) {
    let block = new Block({
      x: col * blockSize,
      y: row * blockSize,
      size: blockSize,
      padding: 1,
    })
    block.userData.rowNumber = row + 1;
    block.userData.colNumber = col + 1;
    block.userData.around = {};
    block.updatePath();
    mainrenderer.scene.add(block);
  }
}

首先是初始化所有格子间的初始状态。使用格子间实例对象的一个属性存储格子间的状态,比如开启中则为true,关闭中则为false,也可用0和1代替。使用Math的random方法随机为该状态赋值。在渲染格子间时,通过判断该属性的真假决定渲染哪种状态的神仙。

行列推出相邻八个格子间的行列数

// 初始化每个点的四周8个位置的引用
mainrenderer.scene.children.forEach(mainBlock => {
  let mainBlockRow = mainBlock.userData.rowNumber;
  let mainBlockCol = mainBlock.userData.colNumber;
  for (let i = 0; i < mainrenderer.scene.children.length; i++) {
    let block = mainrenderer.scene.children[i];
    let blockRow = block.userData.rowNumber;
    let blockCol = block.userData.colNumber;
    // 左上
    if (blockRow == mainBlockRow - 1 && blockCol == mainBlockCol - 1) mainBlock.userData.around[1] = block;
    // 上
    if (blockRow == mainBlockRow - 1 && blockCol == mainBlockCol) mainBlock.userData.around[2] = block;
    // 右上
    if (blockRow == mainBlockRow - 1 && blockCol == mainBlockCol + 1) mainBlock.userData.around[3] = block;
    // 右
    if (blockRow == mainBlockRow && blockCol == mainBlockCol + 1) mainBlock.userData.around[4] = block;
    // 右下
    if (blockRow == mainBlockRow + 1 && blockCol == mainBlockCol + 1) mainBlock.userData.around[5] = block;
    // 下
    if (blockRow == mainBlockRow + 1 && blockCol == mainBlockCol) mainBlock.userData.around[6] = block;
    // 左下
    if (blockRow == mainBlockRow + 1 && blockCol == mainBlockCol - 1) mainBlock.userData.around[7] = block;
    // 左
    if (blockRow == mainBlockRow && blockCol == mainBlockCol - 1) mainBlock.userData.around[8] = block;
  }
})

其次是初始化每个格子间的相邻八个格子间的引用关系。每一个格子间都应该知道自己在第几行和第几列,根据行数与列数通过加减运算推出相邻八个格子间的位置,继而得到该行列数对应的格子间的对象引用。

控制生命游戏的开始暂停与重置

let timer;
function play() {
  timer = setInterval(() => {
    mainrenderer.scene.children.forEach(block => {
      let lifeNum = 0;
      for (let i in block.userData.around) {
        if (block.userData.around[i].isLive) {
          lifeNum++;
        }
      }
      if (lifeNum == 2) {
        block.isNextLive = block.isLive;
      } else if (lifeNum == 3) {
        block.isNextLive = true;
      } else {
        block.isNextLive = false;
      }
    })
    mainrenderer.reRender();
    mainrenderer.scene.children.forEach(block => {
      if (block.isNextLive === undefined) {
        block.isNextLive = block.isLive;
      } else if (block.isNextLive === true) {
        block.isLive = true
      } else {
        block.isLive = false
      }
    })
  }, 100);
}

function stop() {
  if (timer) {
    clearInterval(timer);
  }
  mainrenderer.reRender();
}

最后一步初始化启动回合计数器,调整计数器的间隔时间长短,控制每一个回合的快慢。与此相关的,暂停与继续回合计数器,还有重置所有格子间,采用手动方式召唤出住在里面的神仙。

稳定态

生命游戏中处于稳定态的形状

我在"玩"生命游戏的时候,发现有些格子之间最终会进入"死局"状态,如果它们不再被其他格子"打扰",将会在以后的每一个回合都不再改变状态。要么就处于一种周期性变化的动态平衡之中。

如果生命游戏构建了一个虚拟的世界,那么这种由格子组成的稳定状态就是这个世界的稳定结构。当彼此相邻的格子偶然间组成的马赛克图形具有对称性,他们更容易进入稳定态。

出世心做入世事。跳脱对待人和事。细水长流。织布心态。

延伸实验项目

生命的游戏让我联想到两件可以着手做的事情。第一件事是一张逐渐放大的位图逐渐呈现出构成图像的颜色块,那么足够多的颜色块按照一定规则有序排列能呈现一幅抽象画面,或者将一张图像进行马赛克处理,将每一个色块对应到生命游戏的格子中,开始运行,最终又会变化出怎么样的图像。

第二件事是俄罗斯方块,我可以尝试加入新的想法创作一款类似俄罗斯方块的小游戏。

请使用Github账号登录留言