第 3 版现已推出。在此阅读

第 7 章
项目: 电子生命

[...] 是否机器能思考 [...] 的问题,与潜艇是否会游泳一样无关紧要。

埃兹格·迪克斯特拉,《对计算机科学的威胁》

在“项目”章节中,我会暂时停下来,不再向你灌输新的理论,而是和你一起完成一个程序。理论在学习编程时必不可少,但应该与阅读和理解非平凡的程序相结合。

本章的项目是构建一个虚拟生态系统,一个充满着四处移动并为生存而奋斗的生物的小世界。

定义

为了使这项任务易于管理,我们将极大地简化世界的概念。也就是说,世界将是一个二维网格,其中每个实体占据网格的一个完整方格。在每次回合中,所有生物都有机会采取一些行动。

因此,我们将时间和空间都切分成固定大小的单元:空间为方格,时间为回合。当然,这是一种比较粗糙和不准确的近似。但我们的模拟旨在娱乐,而不是准确,所以我们可以随意取舍。

我们可以用一个计划来定义一个世界,一个字符串数组,使用每个方格一个字符来布置世界的网格。

var plan = ["############################",
            "#      #    #      o      ##",
            "#                          #",
            "#          #####           #",
            "##         #   #    ##     #",
            "###           ##     #     #",
            "#           ###      #     #",
            "#   ####                   #",
            "#   ##       o             #",
            "# o  #         o       ### #",
            "#    #                     #",
            "############################"];

此计划中的“#”字符代表墙壁和岩石,而“o”字符代表生物。空格,正如你可能猜到的,是空旷的空间。

计划数组可用于创建世界对象。这样的对象会跟踪世界的尺寸和内容。它有一个toString方法,将世界转换回可打印的字符串(类似于它所基于的计划),以便我们能够看到内部发生了什么。世界对象还具有一个turn方法,它允许其中的所有生物进行一回合,并更新世界以反映它们的行动。

表示空间

模拟世界的网格具有固定的宽度和高度。方格通过它们的 x 和 y 坐标来标识。我们使用一个简单的类型,Vector(如上一章中的练习所示),来表示这些坐标对。

function Vector(x, y) {
  this.x = x;
  this.y = y;
}
Vector.prototype.plus = function(other) {
  return new Vector(this.x + other.x, this.y + other.y);
};

接下来,我们需要一个对象类型来模拟网格本身。网格是世界的一部分,但我们将其作为独立的对象(将是世界对象的属性)来保持世界对象本身的简单性。世界应该关注与世界相关的事物,而网格应该关注与网格相关的事物。

为了存储一组值的网格,我们有几个选择。我们可以使用一个行数组的数组,并使用两次属性访问来获取特定方格,就像这样

var grid = [["top left",    "top middle",    "top right"],
            ["bottom left", "bottom middle", "bottom right"]];
console.log(grid[1][2]);
// → bottom right

或者我们可以使用一个单个数组,大小为宽度×高度,并决定在(x,y)处的元素位于数组中的位置x + (y × 宽度)。

var grid = ["top left",    "top middle",    "top right",
            "bottom left", "bottom middle", "bottom right"];
console.log(grid[2 + (1 * 3)]);
// → bottom right

由于对这个数组的实际访问将被包裹在网格对象类型的方法中,因此对外部代码来说,我们采用哪种方法并不重要。我选择了第二种表示方法,因为它使创建数组变得容易得多。当使用单个数字作为参数调用Array构造函数时,它会创建一个给定长度的新空数组。

此代码定义了Grid对象,它具有一些基本方法

function Grid(width, height) {
  this.space = new Array(width * height);
  this.width = width;
  this.height = height;
}
Grid.prototype.isInside = function(vector) {
  return vector.x >= 0 && vector.x < this.width &&
         vector.y >= 0 && vector.y < this.height;
};
Grid.prototype.get = function(vector) {
  return this.space[vector.x + this.width * vector.y];
};
Grid.prototype.set = function(vector, value) {
  this.space[vector.x + this.width * vector.y] = value;
};

这是一个简单的测试

var grid = new Grid(5, 5);
console.log(grid.get(new Vector(1, 1)));
// → undefined
grid.set(new Vector(1, 1), "X");
console.log(grid.get(new Vector(1, 1)));
// → X

生物的编程接口

在我们开始World构造函数之前,我们必须对将生活在其中的生物对象更加具体。我提到过,世界会询问生物它们想要采取什么行动。其工作原理如下:每个生物对象都有一个act方法,当调用该方法时,它会返回一个动作。动作是一个具有type属性的对象,该属性命名生物想要采取的动作类型,例如"move"。动作还可能包含额外的信息,例如生物想要移动的方向。

生物非常短视,只能看到网格上直接围绕它们周围的方格。但即使是这种有限的视野,在决定采取什么行动时也是有用的。当调用act方法时,它会获得一个view对象,该对象允许生物检查其周围环境。我们用它们的方位来命名八个周围的方格:"n"表示北,"ne"表示东北,依此类推。以下是我们将用来将方向名称映射到坐标偏移量的对象

var directions = {
  "n":  new Vector( 0, -1),
  "ne": new Vector( 1, -1),
  "e":  new Vector( 1,  0),
  "se": new Vector( 1,  1),
  "s":  new Vector( 0,  1),
  "sw": new Vector(-1,  1),
  "w":  new Vector(-1,  0),
  "nw": new Vector(-1, -1)
};

view 对象有一个look方法,它接受一个方向并返回一个字符,例如,当该方向上有墙壁时返回"#",或者当该方向上没有东西时返回" "(空格)。该对象还提供方便的方法findfindAll。两者都接受一个地图字符作为参数。第一个返回可以在生物旁边找到该字符的方向,或者如果不存在这样的方向则返回null。第二个返回一个包含所有具有该字符的方向的数组。例如,一只坐在墙壁左侧(西侧)的生物在用"#"字符作为参数调用其 view 对象的findAll方法时,将得到["ne", "e", "se"]

这是一个简单而愚蠢的生物,它只会一直跟着它的鼻子走,直到它撞到障碍物,然后就随机反弹到一个开放的方向

function randomElement(array) {
  return array[Math.floor(Math.random() * array.length)];
}

var directionNames = "n ne e se s sw w nw".split(" ");

function BouncingCritter() {
  this.direction = randomElement(directionNames);
};

BouncingCritter.prototype.act = function(view) {
  if (view.look(this.direction) != " ")
    this.direction = view.find(" ") || "s";
  return {type: "move", direction: this.direction};
};

randomElement辅助函数只是从数组中随机选择一个元素,使用Math.random加上一些算术运算来获取随机索引。我们稍后会再次使用它,因为随机性在模拟中可能很有用。

为了选择一个随机方向,BouncingCritter构造函数在方向名称数组上调用randomElement。我们也可以使用Object.keys从我们之前定义的directions对象中获取这个数组,但它不提供关于属性列表顺序的任何保证。在大多数情况下,现代 JavaScript 引擎将按定义顺序返回属性,但它们不需要这样做。

act方法中的“|| "s"”是为了防止this.direction在生物以某种方式被困,周围没有空旷的空间(例如,当被其他生物挤到角落时)而获得值为null

世界对象

现在我们可以开始研究World对象类型了。构造函数接受一个计划(表示世界网格的字符串数组,之前描述过)和一个图例作为参数。图例是一个对象,它告诉我们地图中的每个字符意味着什么。它包含每个字符的构造函数——除了空格字符,它始终代表null,这是我们用来表示空旷空间的值。

function elementFromChar(legend, ch) {
  if (ch == " ")
    return null;
  var element = new legend[ch]();
  element.originChar = ch;
  return element;
}

function World(map, legend) {
  var grid = new Grid(map[0].length, map.length);
  this.grid = grid;
  this.legend = legend;

  map.forEach(function(line, y) {
    for (var x = 0; x < line.length; x++)
      grid.set(new Vector(x, y),
               elementFromChar(legend, line[x]));
  });
}

elementFromChar中,首先我们通过查找字符的构造函数并对其应用new来创建一个正确类型的实例。然后,我们向其添加一个originChar属性,以便轻松找到元素最初是从哪个字符创建的。

在实现世界的toString方法时,我们需要这个originChar属性。该方法通过对网格上的方格执行二维循环,从世界的当前状态构建一个类似地图的字符串。

function charFromElement(element) {
  if (element == null)
    return " ";
  else
    return element.originChar;
}

World.prototype.toString = function() {
  var output = "";
  for (var y = 0; y < this.grid.height; y++) {
    for (var x = 0; x < this.grid.width; x++) {
      var element = this.grid.get(new Vector(x, y));
      output += charFromElement(element);
    }
    output += "\n";
  }
  return output;
};

墙壁是一个简单的对象——它仅用于占据空间,没有act方法。

function Wall() {}

当我们尝试World对象时,通过基于本章前面的计划创建一个实例,然后调用它的toString方法,我们会得到一个与我们输入的计划非常相似的字符串。

var world = new World(plan, {"#": Wall,
                             "o": BouncingCritter});
console.log(world.toString());
// → ############################
//   #      #    #      o      ##
//   #                          #
//   #          #####           #
//   ##         #   #    ##     #
//   ###           ##     #     #
//   #           ###      #     #
//   #   ####                   #
//   #   ##       o             #
//   # o  #         o       ### #
//   #    #                     #
//   ############################

this 及其作用域

World构造函数包含对forEach的调用。值得注意的一点是,在传递给forEach的函数内部,我们不再直接位于构造函数的函数作用域中。每个函数调用都有自己的this绑定,因此内部函数中的this引用外部this所引用的新构造对象。事实上,当一个函数没有作为方法调用时,this将引用全局对象。

这意味着我们不能编写this.grid来从循环内部访问网格。相反,外部函数通过一个正常的局部变量grid创建了一个正常的局部变量,内部函数通过该变量访问网格。

这在 JavaScript 中是一个设计上的失误。幸运的是,该语言的下一个版本为此问题提供了解决方案。同时,也有一些解决方法。一种常见的模式是说var self = this,然后引用self,它是一个正常的变量,因此对内部函数可见。

另一种解决方案是使用bind方法,它允许我们提供一个显式的this对象来绑定。

var test = {
  prop: 10,
  addPropTo: function(array) {
    return array.map(function(elt) {
      return this.prop + elt;
    }.bind(this));
  }
};
console.log(test.addPropTo([5]));
// → [15]

传递给map的函数是bind调用的结果,因此它的this绑定到传递给bind的第一个参数——外部函数的this值(它保存test对象)。

数组上大多数标准的高阶方法(如forEachmap)接受一个可选的第二个参数,它也可以用来为迭代函数调用提供一个this。因此,你可以用稍微简单一点的方式来表达之前的示例。

var test = {
  prop: 10,
  addPropTo: function(array) {
    return array.map(function(elt) {
      return this.prop + elt;
    }, this); // ← no bind
  }
};
console.log(test.addPropTo([5]));
// → [15]

这仅适用于支持这种上下文参数的高阶函数。如果没有,你需要使用其他方法。

在我们自己的高阶函数中,我们可以通过使用call方法调用作为参数给出的函数来支持这样的上下文参数。例如,以下是我们Grid类型的forEach方法,该方法对网格中每个非空或非未定义的元素调用给定函数

Grid.prototype.forEach = function(f, context) {
  for (var y = 0; y < this.height; y++) {
    for (var x = 0; x < this.width; x++) {
      var value = this.space[x + y * this.width];
      if (value != null)
        f.call(context, value, new Vector(x, y));
    }
  }
};

生命动画

下一步是为世界对象编写一个turn方法,让生物有机会行动。它将使用我们刚刚定义的forEach方法遍历网格,查找具有act方法的对象。当找到一个时,turn调用该方法来获取一个动作对象,并在动作有效时执行该动作。目前,只理解"move"动作。

这种方法存在一个潜在问题。你能发现它吗?如果我们让生物在遇到它们时移动,它们可能会移动到我们还没有查看过的方格,当我们到达那个方格时,我们将允许它们再次移动。因此,我们必须保存一个已经行动过的生物数组,并在再次看到它们时忽略它们。

World.prototype.turn = function() {
  var acted = [];
  this.grid.forEach(function(critter, vector) {
    if (critter.act && acted.indexOf(critter) == -1) {
      acted.push(critter);
      this.letAct(critter, vector);
    }
  }, this);
};

我们使用网格forEach方法的第二个参数来访问内部函数中正确的thisletAct方法包含允许生物移动的实际逻辑。

World.prototype.letAct = function(critter, vector) {
  var action = critter.act(new View(this, vector));
  if (action && action.type == "move") {
    var dest = this.checkDestination(action, vector);
    if (dest && this.grid.get(dest) == null) {
      this.grid.set(vector, null);
      this.grid.set(dest, critter);
    }
  }
};

World.prototype.checkDestination = function(action, vector) {
  if (directions.hasOwnProperty(action.direction)) {
    var dest = vector.plus(directions[action.direction]);
    if (this.grid.isInside(dest))
      return dest;
  }
};

首先,我们简单地要求生物行动,传递给它一个了解世界和生物在世界中的当前位置的视图对象(我们将在稍后定义View)。act方法返回某种类型的动作。

如果动作的type不是"move",则忽略它。如果"move",如果它有一个direction属性,该属性引用一个有效的方向,并且如果该方向的方格为空(null),我们将将生物曾经所在的方格设置为null,并将生物存储在目标方格中。

注意,letAct会小心地忽略无意义的输入——它不假设动作的direction属性有效,也不假设type属性有意义。这种防御性编程在某些情况下是有意义的。这样做的主要原因是验证来自你无法控制的来源的输入(例如用户或文件输入),但它也可以用于隔离子系统。在本例中,目的是让生物本身可以用粗心大意的方式编程——它们不必验证其预期动作是否有意义。它们只需请求一个动作,而世界会决定是否允许它。

这两个方法不是World对象的外部接口的一部分。它们是内部细节。某些语言提供了一种方法来明确声明某些方法和属性为私有,并在你尝试从对象外部使用它们时发出错误信号。JavaScript 没有,因此你必须依赖于某种其他形式的通信来描述什么是对象接口的一部分。有时使用命名方案来区分外部和内部属性会有所帮助,例如,在所有内部属性前面加上一个下划线字符 (_) 。这将使意外使用不属于对象接口的一部分的属性更容易发现。

缺少的一部分,View类型,看起来像这样

function View(world, vector) {
  this.world = world;
  this.vector = vector;
}
View.prototype.look = function(dir) {
  var target = this.vector.plus(directions[dir]);
  if (this.world.grid.isInside(target))
    return charFromElement(this.world.grid.get(target));
  else
    return "#";
};
View.prototype.findAll = function(ch) {
  var found = [];
  for (var dir in directions)
    if (this.look(dir) == ch)
      found.push(dir);
  return found;
};
View.prototype.find = function(ch) {
  var found = this.findAll(ch);
  if (found.length == 0) return null;
  return randomElement(found);
};

look方法计算出我们试图查看的坐标,如果它们在网格内,则找到位于那里的元素对应的字符。对于网格外的坐标,look简单地假装那里有一堵墙,这样,如果你定义了一个没有围墙的世界,生物仍然不会试图走出边缘。

它移动

我们之前实例化了一个世界对象。现在我们已经添加了所有必要的方法,应该可以实际让世界移动了。

for (var i = 0; i < 5; i++) {
  world.turn();
  console.log(world.toString());
}
// → … five turns of moving critters

但是,简单地打印出地图的许多副本是一种相当令人不愉快的方式来观察世界。这就是为什么沙盒提供了一个animateWorld函数,该函数将以屏幕动画的形式运行世界,每秒移动三次,直到你按下停止按钮。

animateWorld(world);
// → … life!

animateWorld的实现现在仍然是一个谜,但在你阅读了本书的后面的章节(讨论了 JavaScript 在 Web 浏览器中的集成)之后,它将不再显得那么神奇了。

更多生命形式

如果你观察一段时间,我们世界的戏剧性亮点是两只生物互相弹开。你能想到另一种有趣的行为形式吗?

我想出的一个生物是沿着墙壁移动。从概念上讲,生物将它的左手(爪子,触手,无论什么)放在墙上,然后沿着墙走。事实证明,这并非完全容易实现。

我们需要能够用指南针方向“计算”。由于方向由一组字符串建模,因此我们需要定义自己的操作(dirPlus)来计算相对方向。因此,dirPlus("n", 1) 表示从北向顺时针旋转 45 度,得到 "ne"。类似地,dirPlus("s", -2) 表示从南向逆时针旋转 90 度,也就是东。

function dirPlus(dir, n) {
  var index = directionNames.indexOf(dir);
  return directionNames[(index + n + 8) % 8];
}

function WallFollower() {
  this.dir = "s";
}

WallFollower.prototype.act = function(view) {
  var start = this.dir;
  if (view.look(dirPlus(this.dir, -3)) != " ")
    start = this.dir = dirPlus(this.dir, -2);
  while (view.look(this.dir) != " ") {
    this.dir = dirPlus(this.dir, 1);
    if (this.dir == start) break;
  }
  return {type: "move", direction: this.dir};
};

act方法只需要从生物的左侧开始,顺时针扫描生物周围环境,直到找到一个空的方格。然后它向那个空方格的方向移动。

使事情变得复杂的是,生物可能最终会出现在空旷空间的中间,无论是作为它的起始位置,还是作为绕过另一个生物的结果。如果我们对空旷空间应用我刚刚描述的方法,可怜的生物将一直不停地向左转,绕圈子跑。

因此,有一个额外的检查(if 语句)来仅在看起来生物刚刚经过某种障碍物时才开始从左侧开始扫描——也就是说,如果生物的后面和左侧的空间不是空的。否则,生物会直接向前开始扫描,这样它在空旷空间时会直走。

最后,还有一个测试比较每次循环后this.dirstart,以确保当生物被围墙或其他生物包围,并且找不到空方格时,循环不会永远运行。

这个小世界展示了沿着墙壁行走的生物

animateWorld(new World(
  ["############",
   "#     #    #",
   "#   ~    ~ #",
   "#  ##      #",
   "#  ##  o####",
   "#          #",
   "############"],
  {"#": Wall,
   "~": WallFollower,
   "o": BouncingCritter}
));

更逼真的模拟

为了使我们世界中的生命更有趣,我们将添加食物和繁殖的概念。世界上每个生物都获得一个新的属性energy,它通过执行动作而减少,通过吃东西而增加。当生物有足够的能量时,它可以繁殖,产生一个相同种类的新的生物。为了简单起见,我们世界中的生物无性繁殖,都是由它们自己完成的。

如果生物只在周围走动并互相吃掉,那么世界很快就会屈服于熵增加的定律,耗尽能量,变成一个无生命的荒原。为了防止这种情况发生(至少是很快发生),我们向世界中添加了植物。植物不会移动。它们只使用光合作用来生长(即增加能量)和繁殖。

为了使这项工作正常进行,我们需要一个具有不同letAct方法的世界。我们可以简单地替换World原型的该方法,但我已经非常喜欢我们使用沿着墙壁行走的生物的模拟,并且不想破坏那个旧世界。

一种解决方案是使用继承。我们创建一个新的构造函数LifelikeWorld,其原型基于World原型,但它覆盖了letAct方法。新的letAct方法将实际执行动作的工作委托给存储在actionTypes对象中的各种函数。

function LifelikeWorld(map, legend) {
  World.call(this, map, legend);
}
LifelikeWorld.prototype = Object.create(World.prototype);

var actionTypes = Object.create(null);

LifelikeWorld.prototype.letAct = function(critter, vector) {
  var action = critter.act(new View(this, vector));
  var handled = action &&
    action.type in actionTypes &&
    actionTypes[action.type].call(this, critter,
                                  vector, action);
  if (!handled) {
    critter.energy -= 0.2;
    if (critter.energy <= 0)
      this.grid.set(vector, null);
  }
};

新的letAct方法首先检查是否返回了任何动作,然后检查是否为这种类型的动作存在处理函数,最后检查该处理函数是否返回了true,表示它成功处理了该动作。注意使用call来让处理函数通过它的this绑定访问世界。

如果动作由于某种原因没有生效,则默认动作是生物简单地等待。它损失了五分之一的能量,如果它的能量水平降至零或以下,生物就会死亡,并从网格中移除。

动作处理程序

生物可以执行的最简单的动作是"grow",植物使用它。当返回一个像{type: "grow"}这样的动作对象时,将调用以下处理程序方法

actionTypes.grow = function(critter) {
  critter.energy += 0.5;
  return true;
};

生长总是成功,并为植物的能量水平增加一半。

移动更加复杂。

actionTypes.move = function(critter, vector, action) {
  var dest = this.checkDestination(action, vector);
  if (dest == null ||
      critter.energy <= 1 ||
      this.grid.get(dest) != null)
    return false;
  critter.energy -= 1;
  this.grid.set(vector, null);
  this.grid.set(dest, critter);
  return true;
};

此动作首先使用我们之前定义的checkDestination方法检查动作是否提供了有效的目的地。如果不是,或者目的地不是空的,或者生物缺乏所需的能量,move返回false,表示没有采取任何动作。否则,它会移动生物并减去能量消耗。

除了移动,生物还可以吃东西。

actionTypes.eat = function(critter, vector, action) {
  var dest = this.checkDestination(action, vector);
  var atDest = dest != null && this.grid.get(dest);
  if (!atDest || atDest.energy == null)
    return false;
  critter.energy += atDest.energy;
  this.grid.set(dest, null);
  return true;
};

吃掉另一个生物也需要提供一个有效的目标方格。这次,目的地不能是空的,并且必须包含具有能量的东西,比如一个生物(但不能是墙——墙不可食用)。如果是这样,被吃者的能量被转移到吃者身上,受害者从网格中移除。

最后,我们允许我们的生物繁殖。

actionTypes.reproduce = function(critter, vector, action) {
  var baby = elementFromChar(this.legend,
                             critter.originChar);
  var dest = this.checkDestination(action, vector);
  if (dest == null ||
      critter.energy <= 2 * baby.energy ||
      this.grid.get(dest) != null)
    return false;
  critter.energy -= 2 * baby.energy;
  this.grid.set(dest, baby);
  return true;
};

繁殖的成本是新生生物能量水平的两倍。因此,我们首先使用elementFromChar在生物本身的起源字符上创建一个(假设的)婴儿。一旦我们有了婴儿,我们就可以找到它的能量水平,并测试父母是否有足够的能量成功地将其带到世界上。我们还需要一个有效的(且为空)目标位置。

如果一切正常,婴儿就会被放到网格上(它现在不再是假设的),能量也会被消耗掉。

填充新世界

我们现在有了模拟这些更逼真的生物的框架。我们可以将旧世界中的生物放进去,但它们会因为没有能量属性而死亡。所以让我们创造新的生物。首先,我们来编写一个植物,它是一种相当简单的生命形式。

function Plant() {
  this.energy = 3 + Math.random() * 4;
}
Plant.prototype.act = function(view) {
  if (this.energy > 15) {
    var space = view.find(" ");
    if (space)
      return {type: "reproduce", direction: space};
  }
  if (this.energy < 20)
    return {type: "grow"};
};

植物开始时的能量水平在 3 到 7 之间,随机分布,这样它们就不会在同一个回合内全部繁殖。当植物达到 15 个能量点并且附近有空位时,它会繁殖到那个空位。如果植物不能繁殖,它就会一直生长,直到达到 20 个能量水平。

现在我们定义一个食草动物。

function PlantEater() {
  this.energy = 20;
}
PlantEater.prototype.act = function(view) {
  var space = view.find(" ");
  if (this.energy > 60 && space)
    return {type: "reproduce", direction: space};
  var plant = view.find("*");
  if (plant)
    return {type: "eat", direction: plant};
  if (space)
    return {type: "move", direction: space};
};

我们将使用*字符代表植物,因此当它寻找食物时,就会寻找这个字符。

赋予生命

这给了我们足够的元素来尝试我们的新世界。想象一下,以下地图是一个草木繁盛的山谷,里面有一群食草动物、一些巨石以及随处可见的茂盛植物。

var valley = new LifelikeWorld(
  ["############################",
   "#####                 ######",
   "##   ***                **##",
   "#   *##**         **  O  *##",
   "#    ***     O    ##**    *#",
   "#       O         ##***    #",
   "#                 ##**     #",
   "#   O       #*             #",
   "#*          #**       O    #",
   "#***        ##**    O    **#",
   "##****     ###***       *###",
   "############################"],
  {"#": Wall,
   "O": PlantEater,
   "*": Plant}
);

让我们看看运行这个程序会发生什么。

animateWorld(valley);

大多数情况下,植物会迅速繁殖和扩展,但随之而来的是充足的食物导致食草动物的种群数量激增,它们会消灭所有或几乎所有植物,导致生物大规模饥饿。有时,生态系统会恢复,并开始新一轮循环。在其他情况下,其中一个物种会完全灭绝。如果是食草动物,整个空间都会被植物填满。如果是植物,剩下的生物就会饿死,山谷就会变成一片荒凉的废墟。啊,大自然的残酷。

练习

人工智能

让我们世界中的居民在几分钟内灭绝是一种令人沮丧的事情。为了解决这个问题,我们可以尝试创建一个更聪明的食草动物。

我们的食草动物有几个明显的问题。首先,它们贪得无厌,会吃掉它们看到的每一株植物,直到它们消灭了当地的植物。其次,它们的随机移动(回想一下view.find方法在多个方向匹配时会返回一个随机方向)导致它们无效率地四处游荡,如果附近没有植物就会饿死。最后,它们繁殖速度非常快,这使得丰收和饥荒之间的周期非常激烈。

编写一个新的生物类型,尝试解决这些问题中的一个或多个,并在山谷世界中用它代替旧的PlantEater类型。看看它的表现。如果需要,可以对其进行更多调整。

// Your code here
function SmartPlantEater() {}

animateWorld(new LifelikeWorld(
  ["############################",
   "#####                 ######",
   "##   ***                **##",
   "#   *##**         **  O  *##",
   "#    ***     O    ##**    *#",
   "#       O         ##***    #",
   "#                 ##**     #",
   "#   O       #*             #",
   "#*          #**       O    #",
   "#***        ##**    O    **#",
   "##****     ###***       *###",
   "############################"],
  {"#": Wall,
   "O": SmartPlantEater,
   "*": Plant}
));

贪婪问题可以通过多种方法解决。生物可以在达到一定能量水平后停止进食。或者它们可以每隔 N 个回合进食一次(在生物对象的一个属性中保留自上次进食以来的回合数)。或者,为了确保植物永远不会完全灭绝,动物可以拒绝吃掉一株植物,除非它们看到至少另一株植物在附近(在视图上使用findAll方法)。这些方法的组合,或者一些完全不同的策略,也可能奏效。

让生物更有效地移动可以通过从我们旧的、无能量世界的生物中窃取一种移动策略来实现。弹跳行为和循墙行为都比完全随机的蹒跚行走显示出更广泛的移动范围。

让生物繁殖速度变慢很简单。只需提高它们繁殖的最低能量水平。当然,让生态系统更稳定也会让它变得更无聊。如果你有一小群永远在一片植物海洋中啃食、永不繁殖的肥胖、不动的生物,那将是一个非常稳定的生态系统。但没有人想看这个。

捕食者

任何一个严肃的生态系统都有一个比单链更长的食物链。编写另一个生物,它通过吃食草动物生物来生存。你会注意到,现在存在多个级别的循环,稳定性更难实现。尝试找到一种策略,使生态系统至少在一段时间内平稳运行。

一个有帮助的事情是让世界变得更大。这样,局部种群的繁荣或萧条就不太可能完全消灭一个物种,而且有空间容纳维持一小部分捕食者种群所需的相对较大的猎物种群。

// Your code here
function Tiger() {}

animateWorld(new LifelikeWorld(
  ["####################################################",
   "#                 ####         ****              ###",
   "#   *  @  ##                 ########       OO    ##",
   "#   *    ##        O O                 ****       *#",
   "#       ##*                        ##########     *#",
   "#      ##***  *         ****                     **#",
   "#* **  #  *  ***      #########                  **#",
   "#* **  #      *               #   *              **#",
   "#     ##              #   O   #  ***          ######",
   "#*            @       #       #   *        O  #    #",
   "#*                    #  ######                 ** #",
   "###          ****          ***                  ** #",
   "#       O                        @         O       #",
   "#   *     ##  ##  ##  ##               ###      *  #",
   "#   **         #              *       #####  O     #",
   "##  **  O   O  #  #    ***  ***        ###      ** #",
   "###               #   *****                    ****#",
   "####################################################"],
  {"#": Wall,
   "@": Tiger,
   "O": SmartPlantEater, // from previous exercise
   "*": Plant}
));

许多与之前练习中相同的技巧也适用于这里。建议让捕食者体型庞大(大量能量)并缓慢繁殖。这将使它们在食草动物稀少时,更容易度过饥饿时期。

除了生存之外,保持其食物来源的存活是捕食者主要目标。找到一些方法,让捕食者在食草动物数量很多时更积极地猎杀,而在猎物稀少时更缓慢地猎杀(或根本不猎杀)。由于食草动物四处移动,仅仅在其他食草动物附近时才吃掉一只食草动物的简单技巧不太可能奏效——这种情况很少发生,你的捕食者会饿死。但你可以跟踪先前回合中的观察结果,将其保存在捕食者对象上的一些数据结构中,并让它根据最近观察到的情况来决定行为。