Capítulo 7Proyecto: Vida Electrónica
[...] La pregunta de si las máquinas pueden pensar [...] es tan relevante como la pregunta de si los submarinos pueden nadar.
In “project” chapters, En los capítulos de proyecto, dejaré de abrumarte con teoría nueva por un breve momento, y en lugar de eso trabajaremos a través de un programa juntos. La teoría es indispensable cuando aprendemos a programar, pero debería ser acompañada de lecturas y la comprensión de programas no triviales.
Nuestro proyecto en este capítulo es construir un ecosistema virtual, un mundo pequeño poblado con bichos que se mueven alrededor y luchan por sobrevivir.
Definición
Para hacer esta tarea manejable, nosotros simplificaremos radicalmente el concepto de mundo (world). Es decir un mundo será una cuadricula de dos dimensiones donde cada entidad ocupa un cuadro completo de ella. En cada turn, todos los bichos tienen oportunidad de hacer alguna acción.
Por lo tanto, cortamos ambos tiempo y espacio en dos unidades con un tamaño fijo: cuadros para espacio y turnos para tiempo. Por supuesto, esto es una burda e imprecisa aproximación. Pero nuestra simulación pretende ser entretenida, no precisa, así que podemos acortar libremente las dimensiones.
Podemos definir un mundo como un plan, una matriz de cadenas que establece la cuadrícula del mundo usando un carácter por cuadro. .
var plan = ["############################", "# # # o ##", "# #", "# ##### #", "## # # ## #", "### ## # #", "# ### # #", "# #### #", "# ## o #", "# o # o ### #", "# # #", "############################"];
El carácter "#" en este programa representa paredes y rocas, y el carácter "o" representa bichos (critters). Los espacios, como posiblemente habrás adivinado, son espacios vacíos.
Una matriz unidimensional puede ser usada para crear un objeto mundo (world). Tal objeto mantiene seguimiento del tamaño y el contenido. El mundo tiene un método toString que convierte al mundo nuevamente en una cadena imprimible (parecida al programa en el que se basó) de manera que podamos ver qué es lo que está pasando dentro. El objeto mundo también tiene un método turn, el cual permite a todos los bichos que lo habitan tomar un turno y luego actualizar el mundo reflejando sus acciones.
Representando el espacio.
La cuadrícula (grid) que modela el mundo tiene un ancho y altura fija. Los cuadros son identificados por sus coordenadas "X" y "Y". Usamos un tipo sencillo, Vector (como los vistos en los ejercicios del capítulo anterior), para representar estas coordenadas en pares.
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); };
A continuacion, necesitamos un tipo de objeto que modele por si mismo la cuadricula (grid). La cuadricula es parte del mundo, pero nosotros estamos haciendo la cuadricula como un objeto separado (que sera una propiedad del objeto mundo) para mantener el objeto world simple. El mundo debe ocuparse de las cosas relacionadas con el mundo, y la cuadricula debe ocuparse de las cosas relacionadas con la cuadricula.
Para almacenar una cuadricula de valores, tenemos varias opciones. Podemos utilizar una matriz de matrices de filas (array of row arrays) y utilizar dos propiedades de acceso para llegar a una cruadricula específica:
var grid = [["top left", "top middle", "top right"], ["bottom left", "bottom middle", "bottom right"]]; console.log(grid[1][2]); // → bottom right
O podemos utilizar una sola matriz, con el tamaño de ancho x alto, y decidir que el elemento en (x,y) se encuentra en la posición x + (y x ancho) de la matriz.
var grid = ["top left", "top middle", "top right", "bottom left", "bottom middle", "bottom right"]; console.log(grid[2 + (1 * 3)]); // → bottom right
Dado que el acceso real a esta matriz (Array) será envuelto en métodos en el objeto de tipo cuadricula, no le importa al código externo cual enfoque tomamos. Elegí la segunda representación, ya que hace que sea mucho más fácil crear la matriz. Al llamar al constructor de Array con un solo número como argumento, se crea una nueva matriz vacía de la longitud dada.
Este código define el objeto cuadricula (Grid) con algunos métodos básicos:
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
Una interfaz para programar bichos (critter)
Antes de que podamos comenzar con nuestro constructor en nuestro mundo, debemos obtener más especificaciones sobre los objetos critter que estarán viviendo dentro de él. Mencioné que el mundo preguntará a las criaturas qué acciones quieren tomar. Esto funciona de esta manera: cada objeto critter tiene un método act que, cuando se lo llama, devuelve una acción. Una acción es un objeto con una propiedad de tipo (type), que indica el tipo de acción que el critter quiere tomar, por ejemplo "mover". La acción también puede contener información adicional, como la dirección en la que el critter quiere moverse.
Los Critters son terriblemente miopes y pueden ver solamente los cuadrados directamente alrededor de ellos en la cuadricula. Pero incluso esta visión limitada puede ser útil al decidir qué acción tomar. Cuando se llama al método act, se recibe un objeto view que permite al critter inspeccionar su entorno. Nombramos los ocho cuadrados circundantes por sus direcciones de la brújula: "n" para el norte, "ne" para el noreste, y así sucesivamente. Este es el objeto que usaremos para asignar los nombres de dirección a los desplazamientos de coordenadas:
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) };
El objeto view tiene un método look, que toma una dirección y devuelve un carácter, por ejemplo "" cuando hay una pared en esa dirección, o " " (espacio) cuando no hay nada allí. El objeto también proporciona los métodos convenientes find y findAll. Ambos toman un carácter de mapa como argumento. El primero devuelve una dirección en la que el carácter se puede encontrar con respecto al critter o devuelve null si no existe tal dirección. El segundo devuelve una matriz que contiene todas las direcciones con ese carácter. Por ejemplo, una criatura sentada a la izquierda (al oeste) de una pared obtendrá ["ne", "e", "se"] al llamar a findAll en su objeto de vista con el carácter "" como argumento.
Aquí está un critter simple y estúpido que sigue su nariz hasta que golpea un obstáculo y luego rebota en una dirección al azar:
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}; };
La función de ayuda randomElement simplemente selecciona un elemento aleatorio de una matriz (Array), usando Math.random más algún cálculo aritmético obtendremos un índice aleatorio. Esto lo usaremos más adelante porque la aleatoriedad puede ser útil en simulaciones.
Para escoger una dirección aleatoria, el constructor BouncingCritter llama a randomElement en una matriz directionNames. También podríamos haber usado Object.keys para obtener esta matriz del objeto directions que definimos anteriormente, pero eso no proporciona garantías sobre el orden en el que se enumeran las propiedades. En la mayoría de las situaciones, los motores de JavaScript modernos devolverán las propiedades en el orden en que se definieron, pero no necesariamente.
El “|| "S"
” en el método act está ahí para evitar que this.direction de valor null si el critter está de alguna manera atrapado sin espacio vacío alrededor de él (por ejemplo, cuando se aglomeran en una esquina con otros critters).
El objeto del mundo
Ahora podemos comenzar con el tipo de objeto World. El constructor toma un plan (la matriz de cadenas que representa la cuadrícula del mundo, descrita anteriormente) y una leyenda (legend) como argumentos. Una leyenda es un objeto que nos dice qué significa cada carácter en el mapa. Contiene un constructor para cada carácter, excepto el carácter de espacio, que siempre refiere a null y es el valor que usaremos para representar el espacio vacío.
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])); }); }
En elementFromChar, primero creamos una instance del tipo correcto buscando el constructor del carácter y aplicándole new. A continuación, agregamos una propiedad originChar a ella para que sea fácil averiguar de qué carácter se creó el elemento originalmente.
Necesitamos esta propiedad originChar al implementar el método toString de word. Este método construye una cadena maplike del estado actual del mundo realizando un bucle bidimensional sobre los cuadros de la cuadrícula.
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; };
Una pared es un objeto simple: se usa sólo para ocupar espacio y no tiene método act.
function Wall() {}
Cuando probamos el objeto World creando una instancia basada en el plan del capítulo anterior y luego invocando toString sobre él, obtenemos una cadena muy similar al plan.
var world = new World(plan, {"#": Wall, "o": BouncingCritter}); console.log(world.toString()); // → ############################ // # # # o ## // # # // # ##### # // ## # # ## # // ### ## # # // # ### # # // # #### # // # ## o # // # o # o ### # // # # # // ############################
This y su alcance
El constructor World contiene una llamada a forEach. Una cosa interesante a notar es que dentro de la función pasada a forEach ya no estamos directamente en el ámbito de funciones del constructor. Cada llamada de función obtiene su propia vinculación para this, por lo que el this en la función interna no se refiere al mismo objeto en la nueva construcción que se refiere this en la funcion externa. De hecho, cuando una función no se llama como un (método), this hará referencia al objeto global.
Esto significa que no podemos escribir this.grid para acceder a la cuadrícula desde dentro del bucle. En su lugar, la función externa crea una variable local normal, grid, a través de la cual la función interna obtiene acceso a la cuadrícula.
Esto es un poco un error de diseño en JavaScript. Afortunadamente, la siguiente versión del lenguaje proporciona una solución para este problema. Mientras tanto, hay soluciones. Un patrón común es decir var self = this y a partir de entonces se refiere a self, que es una variable normal y, por lo tanto, visible a las funciones internas.
Otra solución es utilizar el método bind, que nos permite proporcionar un objeto explícito a enlazar.
var test = { prop: 10, addPropTo: function(array) { return array.map(function(elt) { return this.prop + elt; }.bind(this)); } }; console.log(test.addPropTo([5])); // → [15]
La función pasada al map es el resultado de la llamada de enlace (bind call) y por lo tanto this está vinculado al primer argumento dado a bind, el valor this de la función externa (que contiene el objeto de prueba).
La mayoría de los métodos estándar de orden superior en matrices (arrays), como forEach y map, toman un segundo argumento opcional que también se puede utilizar para proporcionar un this para las llamadas a la función de iteración. Así que podría expresar el ejemplo anterior de una manera un poco más simple.
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]
Esto sólo funciona para las funciones de orden superior que soportan dicho parámetro de contexto. Cuando no lo hacen, tendrá que utilizar uno de los otros enfoques.
En nuestras propias funciones de orden superior, podemos utilizar este parámetro de contexto utilizando el método call para llamar a la función dada como argumento. Por ejemplo, aquí hay un método forEach para nuestro tipo Grid, que llama a una función dada para cada elemento de la cuadrícula que no es null o undefined:
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)); } } };
Animando la vida
El siguiente paso es escribir un método de turn para el objeto word que da a los bichos la oportunidad de actuar. Recorrerá la cuadrícula usando el método forEach que acabamos de definir, buscando objetos con un método act. Cuando encuentre uno, turn llamadra dicho método act para obtener un objeto y llevar a cabo ese tipo de acción. Por ahora, solo se entienden las acciones tipo "mover".
Hay un problema potencial con este enfoque. ¿Puedes detectarlo? Si dejamos que los bichos se muevan al cruzarlos, pueden moverse a un cuadrado que todavía no hemos visto, y les permitiremos moverse de nuevo cuando alcancemos ese cuadrado. Por lo tanto, tenemos que mantener una serie de criaturas que ya han tenido su turno e ignorarlos cuando los vemos de nuevo.
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); };
Utilizamos el segundo parámetro del método forEach de la cuadrícula para poder acceder al this correcto dentro de la función interna. El método letAct contiene la lógica real que permite a los critters moverse.
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; } };
Primero, pedimos simplemente al critter que actúe, pasándola un objeto view con datos sobre el mundo y la posición actual de la criatura (definiremos view en un momento). El método act devuelve una acción de algún tipo.
Si el tipo (type) de la acción no es "mover", se ignora. Si es "mover", si tiene una propiedad de dirección que se refiere a una dirección válida, y si el cuadrado en esa dirección es vacío (null), fijamos el cuadrado donde el critter estaba como null y almacenamos el critter en el cuadrado de destino.
Tenga en cuenta que letAct se encarga de ignorar la entrada no valida, no asume que la propiedad direction de la acción es válida o que la propiedad type tiene sentido. Este tipo de (programación defensiva) tiene sentido en algunas situaciones. La principal razón para hacerlo es validar entradas procedentes de fuentes que no controla (como la entrada de usuario o de archivo), pero también puede ser útil para aislar subsistemas entre sí. En este caso, la intención es que los critters puedan ser programados descuidadamente (no tienen que verificar si sus acciones previstas tienen sentido. Pueden solicitar una acción, y el mundo averiguará si permitirla).
Estos dos métodos no forman parte de la interfaz externa de un objeto World. Ellos son un detalle interno. Algunos lenguajes proporcionan formas de declarar explícitamente ciertos métodos y propiedades privadas y señalan un error cuando intenta utilizarlos desde fuera del objeto. JavaScript no, por lo que tendrá que depender de alguna otra forma de comunicación para describir lo que es parte de la interfaz de un objeto. A veces puede ayudar utilizar un esquema de nomenclatura para distinguir entre propiedades externas e internas, por ejemplo prefijando todas las internas con un carácter de subrayado (_). Esto hará que los usos accidentales de las propiedades que no sean parte de la interfaz de un objeto sean más fáciles de detectar.
La parte que falta, el tipo view, se parece a esto:
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); };
El método look calcula las coordenadas que estamos tratando de ver y, si están dentro de la cuadrícula, encuentra el carácter correspondiente al elemento que se encuentra allí. Para las coordenadas fuera de la cuadrícula, look simplemente finge que hay una pared allí de modo que si usted define un mundo sin paredes, las criaturasno serán tentadas de intentar caminar fuera de los bordes.
Se mueve
Hemos instanciado un objeto del mundo anterior. Ahora que hemos añadido todos los métodos necesarios, debería ser posible hacer que el mundo se mueva.
for (var i = 0; i < 5; i++) { world.turn(); console.log(world.toString()); } // → … five turns of moving critters
Sin embargo, imprimir muchas copias del mapa es una manera bastante desagradable de observar un mundo. Es por eso que el sandbox proporciona una función animateWorld que ejecutará un mundo como una animación en pantalla, moviéndose tres veces por segundo, hasta que pulse el botón de parada.
animateWorld(world); // → … life!
La implementación de animateWorld seguirá siendo un misterio por ahora, pero después de haber leído los capítulos posteriores de este libro, y estudiar la integración de JavaScript en los navegadores web, ya no se verá tan mágico.
Más formas de vida
El punto culminante dramático de nuestro mundo, si usted mira para un pedacito, es cuando dos critters rebotan el uno al otro. ¿Puedes pensar en otra forma interesante de comportamiento?
Podemos hacer un critter que se mueve a lo largo de las paredes (WallFollower). Conceptualmente, el critter mantiene su mano izquierda (pata, tentáculo, lo que sea) a la pared y sigue adelante. Este no es un cambio trivial de implementar:
Tenemos que ser capaces de “hacer calculos" con las direcciones de la brújula. Como las direcciones son modeladas por un conjunto de cadenas, deberíamos definir nuestra propia operación (dirPlus) para calcular direcciones relativas. Así dirPlus ("n", 1) significa una vuelta de 45 grados en sentido horario desde el norte, dando "ne". Del mismo modo, dirPlus ("s", -2) significa 90 grados en sentido antihorario desde el sur, que es el este.
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}; };
El método act sólo tiene que "escanear" el entorno del critter, comenzando por su lado izquierdo y en sentido horario hasta encontrar un cuadrado vacío. Entonces se mueve en la dirección de ese cuadrado vacío.
Lo que complica las cosas es que un critter puede terminar en el medio del espacio vacío, ya sea como su posición inicial o como resultado de caminar alrededor de otro critter. Si aplicamos el enfoque que acabo de describir en el espacio vacío, el pobre critter seguirá girando a la izquierda en cada paso, corriendo en círculos.
De modo que hay una comprobación extra (la sentencia if) para iniciar el escaneado hacia la izquierda sólo si parece que el critter acaba de pasar algún tipo de obstáculo, es decir, si el espacio detrás y hacia la izquierda del critter no está vacío. De lo contrario, el critter comienza a escanear directamente delante, de modo que va a caminar recto en el espacio vacío.
Y finalmente, hay una prueba que compara this.dir con start después de cada paso a través del loop para cerciorarse de que el loop no funcionará para siempre cuando el critter está amurallado adentro o apretado adentro por otros critters y no puede encontrar un cuadrado vacío.
Este pequeño mundo muestra las criaturas que siguen la pared:
animateWorld(new World( ["############", "# # #", "# ~ ~ #", "# ## #", "# ## o####", "# #", "############"], {"#": Wall, "~": WallFollower, "o": BouncingCritter} ));
Una simulación más realista
Para hacer la vida en nuestro mundo más interesante, vamos a añadir los conceptos de la comida y la reproducción. Cada ser vivo en el mundo recibe una nueva propiedad, la energía, que se reduce mediante la realización de acciones y el aumento de comer cosas. Cuando la criatura tiene suficiente energía, puede reproducirse, generando una nueva criatura del mismo tipo. Para mantener las cosas simples, las criaturas en nuestro mundo se reproducen asexualmente por sí solas.
Si los bichos sólo se mueven y comen unos a otros, el mundo pronto sucumbirá a la ley de entropía creciente, se quedará sin energía, y se convertirá en un desierto sin vida. Para evitar que esto suceda (demasiado rápido, al menos), añadimos plantas al mundo. Las plantas no se mueven. Simplemente utilizan la fotosíntesis para crecer (es decir, aumentar su energía) y reproducirse.
Para que esto funcione, necesitaremos un mundo con un método letAct diferente. Podríamos simplemente reemplazar el método del (prototipo world), pero me he vuelto muy apegado a nuestra simulación con las criaturas que siguen a la pared y odiaría romper ese viejo mundo.
Una solución es usar la (herencia). Creamos un nuevo (constructor), LifelikeWorld, cuyo prototipo se basa en el (prototipo World) pero que anula el método letAct. El nuevo método letAct delega el trabajo de realizar una acción a varias funciones almacenadas en el objeto 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); } };
El nuevo método letAct comprueba primero si se ha devuelto una acción, si existe una (función de controlador) para este tipo de acción y, por último, si ese controlador devuelve true, lo que indica que se ha tratado correctamente la acción.
Tenga en cuenta el uso de call para dar al manejador acceso al mundo a través de this. Si la acción no funcionó por alguna razón, la acción predeterminada es que la criatura simplemente espere. Pierde un quinto punto de energía, y si su nivel de energía es igual o menor que cero, la criatura muere y se borra de la cuadrícula.
Manejadores de acciones
actionTypes.grow = function(critter) { critter.energy += 0.5; return true; };
Crecer siempre tiene éxito y añade medio punto al nivel de energía (energy) de la planta. Moverse es más complicado.
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; };
Esta acción comprueba primero, utilizando el método checkDestination definido anteriormente, si la acción proporciona un destino válido. Si no, o si el destino no está vacío, o si el critter carece de la energía requerida, move devuelve false para indicar que no se tomó ninguna acción. De lo contrario, se mueve el critter y resta el costo de la energía.
Además de moverse, las criaturas pueden comer.
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; };
Comer otra criatura (critter) también implica proporcionar un cuadrado de destino válido. Esta vez, el destino no debe estar vacío y debe contener algo con energía, como un critter (pero no una pared - las paredes no son comestibles). Si es así, la energía de la comida es transferida al comedor, y la víctima es removida de la cuadrícula.
Y finalmente, permitimos que nuestras criaturas se reproduzcan.
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; };
Reproducirse cuesta dos veces el nivel de energía de un critter recién nacido. Así que primero creamos un bebé (hipotético) usando elementFromChar en el propio carácter de origen del critter. Una vez que tenemos un bebé, podemos encontrar su nivel de energía y probar si el padre tiene suficiente energía para llevarlo con éxito al mundo. También necesitamos un destino válido (y vacío).
Si todo está bien, el bebé es puesto en la cuadrícula (ya no es hipotético), y la energía se gasta.
Llenar el nuevo mundo
Ahora tenemos un marco (framework) para simular estas criaturas en forma más realista. Podríamos poner las criaturas del viejo mundo en ella, pero sólo morirían ya que no tienen una propiedad de energía. Así que hagamos nuevos. Primero escribiremos una planta (Plant), que es una forma de vida bastante simple.
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"}; };
Las plantas comienzan con un nivel de energía entre 3 y 7, aleatorizado para que no se reproduzcan todos en el mismo turno (turn). Cuando una planta alcanza 15 puntos de energía y hay un espacio vacío cerca, se reproduce en ese espacio vacío. Si una planta no puede reproducirse, simplemente crece hasta alcanzar el nivel de energía 20.
Ahora definimos un comedor de plantas (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}; };
Usaremos el carácter * para las plantas (Plant), así que eso es lo que esta criatura buscará cuando busque comida.
Darle vida
Y eso nos da suficientes elementos para probar nuestro nuevo mundo. Imagínese el siguiente mapa como un valle cubierto de hierba con una manada de herbívoros en ella, algunas rocas y una exuberante vegetación en todas partes.
var valley = new LifelikeWorld( ["############################", "##### ######", "## *** **##", "# *##** ** O *##", "# *** O ##** *#", "# O ##*** #", "# ##** #", "# O #* #", "#* #** O #", "#*** ##** O **#", "##**** ###*** *###", "############################"], {"#": Wall, "O": PlantEater, "*": Plant} );
Veamos ver ocurre cuando ejecutamos este codigo.
animateWorld(valley);
La mayoría de las veces, las plantas se multiplican y se expanden con bastante rapidez, pero luego la abundancia de alimentos causa una explosión poblacional de los herbívoros, que proceden a eliminar todas o casi todas las plantas, resultando en un hambre masiva de las criaturas. A veces, el ecosistema se recupera y comienza otro ciclo. En otras ocasiones, una de las especies muere completamente. Si son los herbívoros, todo el espacio se llenará de plantas. Si son las plantas, las criaturas restantes mueren de hambre, y el valle se convierte en una tierra baldía desolada. Ah, la crueldad de la naturaleza.
Ejercicios
Estupidez artificial
Tener extinguidos a los habitantes de nuestro mundo después de unos minutos es algo deprimente. Para hacer frente a esto, podríamos tratar de crear un comedor de plantas más inteligente (smarter plant eater).
Hay varios problemas obvios con nuestros herbívoros. En primer lugar, son terriblemente codiciosos, tragan cada planta que ven hasta que han borrado toda la vida vegetal. En segundo lugar, su movimiento al azar (recuerde que el método view.find: devuelve una dirección al azar cuando coinciden varias direcciones) hace que tropiecen ineficazmente y mueren de hambre si no encuentran ninguna planta cerca. Y finalmente, se reproducen muy rápido, lo que hace que los ciclos entre la abundancia y el hambre sean bastante intensos.
Crea un nuevo tipo de critter intentando abordar uno o más de estos puntos y sustituyelo por el antiguo tipo PlantEater en el mundo del valle. Haga algunos ajustes mas si lo ve necesario.
// Your code here function SmartPlantEater() {} animateWorld(new LifelikeWorld( ["############################", "##### ######", "## *** **##", "# *##** ** O *##", "# *** O ##** *#", "# O ##*** #", "# ##** #", "# O #* #", "#* #** O #", "#*** ##** O **#", "##**** ###*** *###", "############################"], {"#": Wall, "O": SmartPlantEater, "*": Plant} ));
El problema de la codicia puede ser atacado de varias maneras. Las criaturas podrían dejar de comer cuando alcanzan un cierto nivel de energía. O pueden comer sólo cada N vueltas (manteniendo un contador de las vueltas desde su última comida en una propiedad en el objeto de criatura). O, para asegurarse de que las plantas nunca se extingan por completo, los animales podrían negarse a comer una planta a menos que vean por lo menos una planta cercana (usando el método findAll en la vista). Una combinación de estos, o alguna estrategia totalmente diferente, también podría funcionar
El problema de la codicia puede ser atacado de varias maneras. Las criaturas podrían dejar de comer cuando alcanzan un cierto nivel de energía (energy). O pueden comer sólo cada N vueltas (manteniendo un contador de las vueltas desde su última comida en una propiedad en el objeto critters). O, para asegurarse de que las plantas nunca se extingan por completo, los animales podrían negarse a comer una planta a menos que vean por lo menos una planta cercana (usando el método findAll en la vista). Una combinación de estos, o alguna estrategia totalmente diferente, también podría funcionar.
Hacer que los bichos se muevan más eficazmente se podría hacer copiando una de las estrategias de movimiento de las criaturas en nuestro viejo mundo sin energías. Tanto el comportamiento de rebote (bouncing) como el comportamiento de seguimiento de la pared (wall-following) mostraron un rango de movimiento mucho más amplio que el escalonamiento completamente aleatorio.
Hacer que las criaturas se reproduzcan más lentamente es trivial. Sólo aumentar el nivel mínimo de energía en la que se reproducen. Por supuesto, hacer el ecosistema más estable también lo hace más aburrido. Si tienes un puñado de criaturas gordas e inmóviles reproducirse pero siempre comiendo en un mar de plantas, tienes un ecosistema muy estable, pero muy aburrido.
Depredadores
Cualquier ecosistema serio tiene una cadena alimentaria más larga que un solo eslabón. Escriba otro critter que sobreviva comiendose al critter herbívoro. Usted notará que la estabilidad es aún más difícil de lograr ahora que hay ciclos en múltiples niveles. Trate de encontrar una estrategia para hacer que el ecosistema funcione sin problemas durante al menos un tiempo.
Una cosa que ayudará es hacer el mundo más grande. De esta manera, los auges o bajas de la población local son menos propensos a eliminar completamente una especie, y hay espacio para la población de presas relativamente grande que se necesita para mantener una pequeña población de depredadores.
// 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} ));
Muchos de los mismos trucos que trabajaron para el ejercicio anterior también se aplican aquí. Se recomienda hacer que los depredadores sean grandes (mucha energía) y que se reproduzcan lentamente. Eso los hará menos vulnerables a los períodos de hambre cuando los herbívoros son escasos.
Más allá de mantenerse vivo, mantener vivo su alimento es el principal objetivo de un depredador. Encuentre alguna manera de hacer que los depredadores cazan de manera más agresiva cuando hay muchos herbívoros y cazan más lentamente (o no) cuando la presa es rara. Dado que los comedores de plantas se mueven, el simple truco de comer uno solo cuando otros están cerca no es probable que funcione, eso sucederá tan rara vez que su depredador se muera de hambre. Pero usted podría seguir la pista de observaciones en vueltas anteriores, en una cierta estructura de datos guardada en los objetos del depredador, y tenerla basar su comportamiento (behavior) en lo que ha visto recientemente.