Eloquent JavaScript 笔记 七: Electronic Life

1. Definition

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

构建一个虚拟的生态系统,见上图。该图代表一个小世界,是一个游戏场景。图中的 # 代表墙,o 代表生物。每个回合,生物都会随机移动一个格子,或者移动到空白处,或者撞上墙。

我们所要做的,就是用三种字符(#,o,空格)在console中打印这样的图,以模拟这个生态系统。再写一个turn函数,每调用一次,生物们移动一个回合,然后再打印整张图。

上面的plan就代表了生态系统的一个状态。


1. Representing Space

坐标

用上一章练习题的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);
};

地图

定义一个Grid类,该类内部用一个数组来存储每一个坐标上是什么东西(墙、生物、空白)。

从直观上来看,这个数组应该是二维的,因为,地图就是个二维空间。访问地图上某个坐标:grid[y][x] 。

存储地图的数组也可以定义为一维的,数组的长度为 width x height,访问某个坐标:grid[x+y*width] 。

这个数组是地图对象的一个属性,只有地图对象本身才会直接访问这个数组,所以,对于其他对象,选用哪种方式都无所谓。这里,我们选用一维数组,因为,创建这个数组更简单。

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;
};

1. A Critter's Programming Interface

定义Critter对象的接口

Critter对象只需要一个接口 —— act,就是每个turn(回合)它执行的动作。

要想执行动作,需要了解地理信息,所以,我们定义一个导航仪(高德导航)对象:View。

我们再把 “动作” 抽象成一个对象:Action。拿到这个Action,我们就可以确定这个critter的新位置。

所以,act的定义(用法)为: var action = critter.act(view);

下面先定义View的接口:

方法 find() : 随机找一个可以移动的方向。如果找不到,则返回null。

方法 look() : 给定一个方向,返回该方向上紧邻的字符(#,或空格)。

再定义Action的接口:

属性 type:"move",现在,我们的critter只有这一种action类型。

属性 direction: 移动到哪个方向。如果我们限定critter每次只能移动一格,那direction就是critter周围的八个坐标。用 "n" (北),"ne" (东北)等字符串表示。

通过下面定义的 directions 变量,我们可以方便的把 “移动方向(direction)” 和 “地图坐标” 建立联系。

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、Action和direction的定义,我们可以实现Critter 了:

注意,这里实现的critter有一个默认的移动方向,而且会记录上一个回合的移动方向,也就是说,如果没有遇到障碍,它会沿着一个方向持续移动下去(一次一格)。

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};
};

1. The World Object

有了地图(Grid)和生物(Critter),我们可以构建一个世界(World)了。这一小节的目标就是把 plan 构建成World。

在本章最初定义的plan变量中,我们可以看到,World 中的每个格子会容纳一个element,共三种:墙,Critter, 空白。

墙:

    符号:#

    prototype: Wall

    实现:function Wall() { } ,墙不需要有任何行为和属性,所以,定义一个空的构造函数就够了。

生物:

    符号:o

    prototype: BouncingCritter

    实现:见上一节的代码

空白:

    符号:空格

    prototype:null

    空白 也不需要任何行为和属性,能和墙区分开来就可以。所以,可以用null表示。


通过符号构建element

定义一个对象,容纳各种element的构造函数

var legend = {
  "#": Wall,
  "o": BouncingCritter
};
构建element的函数:

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

通过plan创建初始世界

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]));
  });
}

var world = new World(plan, {"#": Wall, "o": BouncingCritter});

解释一下这个构造函数:

1. 第一个参数map,是像plan那样的数组;
2. 创建grid(地图);

3. 保存legend,以备后用;

4. 根据map中的每一个符号(#,o,空白)创建相应的element,保存到Grid(地图)中。

把世界打印到console

把element转换成符号

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

注意这个originChar,是在上面构建element时保存的。


给World添加toString方法

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;
};

把上面所有的代码都保存到一个html中,在控制台输入: world.toString()




1. This and Its Scope

注意World构造函数的前两行代码

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

为什么要这么写呢,不是可以写成一行吗?

注意,下面的forEach用一个function遍历map的每一行。这个function中用到了grid,注意看,grid前面没有this。this在这个function中并不是World对象,因为,这个function不是World的成员函数。

这是js的一个非常怪异的地方。只有在成员函数中,this才代表对象本身,否则,是指全局对象。而全局对象是没有grid属性的。所以,要额外定义 var grid 。

第二种模式

var self = this; 

在function中使用self,self是一个普通的变量,从语义上来看更清晰一些。

第三种模式,用bind

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

还记得bind吗? 有了bind的第一个参数,function内部的this就不再是全局变量了。

第四种模式

大多数标准的高阶函数,都可以传入第二个参数,这个参数也会被当作function内部的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]

第五种模式

参照第四种模式,我们可以给Grid类定义自己的高阶函数 forEach,注意第二个参数context就是那个this。

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));
    }
  }
};

1. 实现导航仪 —— View

在讨论Critter对象的接口时,曾提到过View对象。每当Critter开始行动时,都需要先拿出导航仪,以确定行动路线。显而易见,导航仪需要两个数据:当前坐标、地图。看下面的构造函数,vector就是坐标,world就是地图。

function View(world, vector) {
  this.world = world;
  this.vector = vector;
}

在BouncingCritter对象中,我们看到,act方法需要使用View对象的两个方法:look 和 find。

look 用于查看某个方向上有什么东西

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 "#";
};

find 方法很简单:给定一个字符,找到周围八个方格中哪个方向包含这个字符,如果找不到,返回null,找到了,随机返回一个。

View.prototype.find = function(ch) {
  var found = this.findAll(ch);
  if (found.length == 0) return null;
  return randomElement(found);
};

find中用到了findAll方法

View.prototype.findAll = function(ch) {
  var found = [];
  for (var dir in directions)
    if (this.look(dir) == ch)
      found.push(dir);
  return found;
};

1. Animating Life

给World添加turn方法

每调用一次turn(一个回合),地图上的每一个critter都有机会移动一次。
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);
};

var acted = [ ]; 这是干什么呢? 我们在forEach循环中遍历地图上的每一个坐标,而该坐标上的critter有可能移动到forEach还没遍历过的坐标上。所以,要把它记下来,如果在一个turn中遇到了两次这个critter,那么,第二次就略过它,不要多次移动。

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;
  }
};

1. It Moves
这个小世界终于构建完了,运行一下看看:

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

1. More Life Forms
这一小节,我们要添加新的生物种类。

对于上面构建的世界,如果我们多看一些回合,就会发现,如果两个critter相遇,它们会自动弹开。

我们可以创造一种新的Critter,它具有不同的行为,例如:顺着墙爬。想象一下,这种新的生物用一只左手(爪子?吸盘?)抓住墙,每个回合都沿着墙移动一格。

爬墙是有方向概念的,首先,需要增加一个方法,计算方向。

dir 代表一个给定的方向,则:

  dir + 1 = 顺时针移动45度方向;

  dir - 2 = 逆时针移动90度方向;

  以此类推。

例如:dirPlus("n", 1) 返回 "ne"

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};
};

我的空间意识太差,中学几何就学的很困难,这个算法实在看不懂。


构建一个新的世界

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

让它动起来看一看。 大笑

1. A More Lifelike Simulation
为了让我们创造的世界更有意思,我们要给它加入食物,要让critter能够繁衍。

给每一种生物加上一个属性energy,每个action会消耗energy,而吃东西会增加energy。当一个critter有了足够的energy,它可以生出一个新的critter。

再增加一种生物 Plant,它不会动,靠光合作用增加成长和繁衍。

为了达到以上目的,需要修改World类,主要是 letAct 方法。但这次我们用继承的方式,创建一种新的世界LifelikeWorld,这样的话,还可以随时创建出以前的世界。

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比较,可以看到,新的letAct不再负责具体action的执行,而是把执行权交给了action自己。这样可以使程序结构更简洁清晰、利于扩展。letAct中只负责一种action,或者说,一种不算是action的行为:静止不动。当critter在一个回合中没有合适的action时,它就静止不动,energy 减去五分之一。

在以前的World中,其实只有一种action —— move。现在要增加三种:eat,grow 和 reproduce。所以,需要一个容器来保存它们。


1. Action Handlers

上一节的actions容器还是空的,现在把它填上。

grow

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

move

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;
};

eat

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;
};

reproduce

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;
};

这个设计不错,如果这一大堆代码都放到World类的letAct方法中,那真会把程序员逼疯的。


1. Populating the New World

再定义两种critter:Plant 和 PlantEater。以前定义的WallFollower和BouncingCritter并不适合这个世界,它们既不能生长,也不能繁衍。


Plant

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"};
};

PlantEater

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};
};

1. Bringing It To Life

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

随书代码中给了一个方法: animateWorld(valley) ,可以在浏览器中自动运行。

我是在console中执行 valley.turn(); valley.toString(); 在console中打印,也能看出动态效果来。一般执行几十次就只剩下plant了。


这一章太长了,而且,代码逻辑相当复杂,真心不容易看懂。我画了一张类图,是加入Plant之前的,依赖关系相当复杂。



这一章看了好几天,习题也懒得做了。不过,这些代码还是挺有意思的,以后抽时间还要拿出来玩一玩。


评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值