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()
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之前的,依賴關系相當複雜。
這一章看了好幾天,習題也懶得做了。不過,這些代碼還是挺有意思的,以後抽時間還要拿出來玩一玩。