天天看點

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

解釋一下這個構造函數:

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()

Eloquent JavaScript 筆記 七: Electronic Life

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

讓它動起來看一看。

Eloquent JavaScript 筆記 七: Electronic Life

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之前的,依賴關系相當複雜。

Eloquent JavaScript 筆記 七: Electronic Life

這一章看了好幾天,習題也懶得做了。不過,這些代碼還是挺有意思的,以後抽時間還要拿出來玩一玩。

繼續閱讀