📜 ⬆️ ⬇️

Expressive JavaScript: E-Life Project

Content




The question of whether machines can be thought as relevant as the question of whether submarines can sail.

Edsger Dijkstra, Threats to Computational Science
')
In the chapters, I will stop quitting theory with you, and will work with you on programs. The theory is indispensable for teaching programming, but it must be accompanied by reading and understanding non-trivial programs.

Our project is the construction of a virtual ecosystem, a small world inhabited by creatures that move and fight for survival.

Definition


To make the task feasible, we will fundamentally simplify the concept of the world. Namely - the world will be a two-dimensional grid, where each entity occupies one cell. On each turn, creatures will be able to perform some action.

Thus, we will chop time and space into units of fixed size: cells for space and moves for time. Of course, this is a rough and careless approach. But our simulation should be entertaining, not neat, so we are free to “cut corners”.

We can define the world with the help of a plan - an array of strings, which decomposes the world grid, using one symbol per cell.

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


The “#” symbol indicates walls and stones, “o” - being. Spaces - empty space.

The plan can be used to create an object of the world. He keeps track of the size and contents of the world. It has a toString method that transforms the world into a display string (such as the plan on which it is based) so that we can observe what is happening inside it. The world object has a turn method that allows all beings to make one move and update the state of the world according to their actions.

We depict the space


At the grid, simulating the world, given the width and height. Cells are defined by x and y coordinates. We use the simple Vector type (from the exercises in the previous chapter) to represent these pairs of coordinates.

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


Then we need an object type that simulates the mesh itself. The grid is a part of the world, but we make a separate object out of it (which will be a property of the world object) in order not to complicate the world object. The world must load itself with things belonging to the world, and the grid with things related to the grid.

We have several options for storing a grid of values. You can use an array of array strings, and use two-step access to properties:

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


Or we can take one array, the size of width × height, and decide that the element (x, y) is in the position x + (y × width).

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


Since the access will be wrapped in the methods of the grid object, the external code doesn't care which approach will be chosen. I chose the second one because it is easier to create an array with it. When you call the Array constructor with one number as an argument, it creates a new empty array of a given length.

The following code declares a Grid object (grid) with the main methods:

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


Elementary test:

 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 


Creature programming interface


Before we take on the constructor of the world World, we need to determine the objects of the creatures that inhabit it. I mentioned that the world will ask beings what they want to do. It will work like this: every creature object has an act method, which when called returns an action action. Action - an object of type property, which names the type of action that a creature wants to perform, for example, “move”. An action may contain additional information, such as driving direction.

The creatures are terribly nearsighted and see only the cells directly adjacent to them. But it can be useful when choosing actions. When calling the act method, it is given a view object, which allows the creature to explore the surrounding terrain. We call the eight neighboring cells their compass directions: “n” to the north, “ne” to the northeast, and so on. Here is the object that will be used to convert from the names of directions to the displacement by coordinates:

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


The view object has a look method that takes direction and returns a character, for example, "#" if there is a wall, or a space, if there is nothing there. The object also provides convenient find and findAll methods. Both accept one of the symbols representing things on the map as an argument. The first returns the direction in which this item can be found next to the creature, or null if there is no such item. The second returns an array with all possible directions where such an item is found. For example, a creature to the left of the wall (in the west) will get [“ne”, “e”, “se”] when calling findAll with argument “#”.

Here is a simple stupid creature that just goes on, until it hits an obstacle, and then bounces off in a random direction.

 function randomElement(array) { return array[Math.floor(Math.random() * array.length)]; } function BouncingCritter() { this.direction = randomElement(Object.keys(directions)); }; BouncingCritter.prototype.act = function(view) { if (view.look(this.direction) != " ") this.direction = view.find(" ") || "s"; return {type: "move", direction: this.direction}; }; 


The helper function randomElement simply selects a random array element using Math.random and some arithmetic to get a random index. We will continue to use chance, since it is a useful thing in simulations.

The BouncingCritter constructor calls Object.keys. We saw this function in the previous chapter — it returns an array with all the object property names. Here it gets all the names of the directions from the directions object specified earlier.

The construction “|| The “s” in the act method is needed so that this.direction does not get null if the creature huddles into a corner without free space around — for example, surrounded by other creatures.

World Object


Now you can proceed to the world object World. The constructor accepts a plan (an array of strings representing a mesh of the world) and a legend object. This is an object that tells you what each of the map symbols means. It has a constructor for each character - except for a space, which refers to null (representing empty space).

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


In elementFromChar, we first create an instance of the desired type, finding the character's constructor and applying new to it. Then we add the originChar property so that it is easy to find out from which character the element was originally created.

We will need this originChar property when making the world toString method. The method builds a map in the form of a line from the current state of the world, passing a two-dimensional cycle through the grid cells.

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


Wall wall is a simple object. Used to occupy space and does not have the act method.

 function Wall() {} 


By checking the World object, creating an instance using the plan specified at the beginning of the chapter, and then calling its toString method, we get a very similar string to this plan.

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


this and its scope

In the World constructor there is a call forEach. I want to note that inside the function passed in forEach, we are no longer directly in the scope of the constructor. Each function call gets its own namespace, so this inside does not already refer to the object being created, which is referenced by this outside the function. And in general, if the function is not called as a method, this will refer to the global object.

This means that we cannot write this.grid to access the grid from within the loop. Instead, the external function creates a local grid variable, through which the internal function accesses the grid.

This is a miss in javascript design. Fortunately, in the next version there is a solution to this problem. In the meantime, there are workarounds. Usually write

 var self = this 


and then work with the self variable.

Another solution is to use the bind method, which allows you to bind to a specific this object.

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


The function passed to map is the result of the call binding, and therefore its this is bound to the first argument passed in bind, that is, the variable to the external function (which contains the test object).

Most standard higher-order methods for arrays, such as forEach and map, take an optional second argument, which can also be used to pass this when calling an iteration function. You could write the previous example a little easier:

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


This only works with higher-order functions that have such a context parameter. If not, you have to use the other approaches mentioned.

In our own higher order function, we can enable context parameter support using the call method to call the function passed as an argument. For example, here is the forEach method for our Grid type, which calls the specified function for each grid element that is not null or 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)); } } }; 


Reviving the world


The next step is to create a turn method (step) for a world object, giving creatures the opportunity to act. It will bypass the grid with the forEach method, and look for objects that have the act method. Finding an object, turn calls this method, getting an action object and performs this action if it is valid. For now, we only understand the “move” action.

There is one possible problem. Can you see which one? If we let the creatures move as we go through them, they can move to a cage that we haven’t processed yet, and then we will let them move again when the cage reaches that cage. Thus, we need to store an array of creatures that have already made their move, and ignore them when re-passing.

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


The second parameter of the forEach method is used to access the correct this variable in an internal function. The letAct method contains logic that allows creatures to move.

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


First, we simply ask the creature to act, passing it the view object, which knows about the world and the current position of the creature in the world (we will soon define the View). The act method returns an action.

If the action type is not “move”, it is ignored. If “move”, and if it has a direction property that refers to a valid direction, and if the cell is empty in this direction (null), we assign the cell where the creature was just, null, and store the creature in the destination cell.

Notice that letAct takes care of ignoring invalid input. It does not assume by default that direction is valid, or that a type property makes sense. This kind of security programming makes sense in some situations. This is mainly done to validate input data coming from sources that you do not control (user input or file reading), but it is also useful for isolating subsystems from each other. In our case, its goal is to consider that creatures can be programmed inaccurately. They do not need to check whether their intentions make sense. They simply request the possibility of action, and the world itself decides whether to allow it.

These two methods do not belong to the world object's external interface. They are parts of the internal implementation. Some languages ​​provide ways to declare certain methods and properties "private" and give an error when trying to use them outside the object. JavaScript does not provide for this, so you have to rely on other ways to report that it is part of the object's interface. Sometimes it helps to use the property naming scheme to distinguish between internal and external, for example, with special prefixes to internal names, such as underscore (_). This will facilitate the identification of random use of properties that are not part of the interface.

And the missing part, type View, looks like this:

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


The look method calculates the coordinates we are trying to look at. If they are inside the grid, then get the character corresponding to the element there. For the coordinates outside the grid, look simply pretend that there is a wall — if you set the world without the surrounding walls, the creatures cannot get off the edge.

It moves


We have created a copy of the world object. Now that all the necessary methods are ready, we should be able to make it move.

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


Simply displaying five copies of a map is not a very convenient way to observe the world. Therefore, in the sandbox for the book (or in files for download ) there is a magical function animateWorld, which shows the world as an animation on the screen, taking three steps per second until you press the stop.

 animateWorld(world); // → … ! 


The implementation of animateWorld will remain secret for now, but after reading the next chapters of the book discussing the integration of JavaScript into browsers, it will no longer look so mysterious.

More life forms


One of the interesting situations in the world happens when two creatures bounce off each other. Can you come up with another interesting form of interaction?

I came up with a creature moving along the wall. It holds its left hand (paw, tentacle, whatever) on the wall and moves along it. This, as it turned out, is not so easy to program.

We will need to calculate using directions in space. Since the directions are given by a set of strings, we need to specify our dirPlus operation to calculate the relative directions. dirPlus (“n”, 1) means turning clockwise 45 degrees to the north, which results in “ne”. dirPlus ("s", -2) means turning counterclockwise from the south, that is, to the east.

 var directionNames = Object.keys(directions); 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}; }; 


The act method only scans the creature’s environment, starting on the left side and further clockwise until it finds an empty cell. Then he moves in the direction of this cell.

Complicating the situation is that the creature may be far from the walls in empty space - either bypassing another creature, or initially there. If we leave the algorithm described, the unfortunate creature will turn left every turn and run in a circle.

So there is another check through if that the scan needs to be started if the creature just passed by any obstacle. That is, if the space behind and to the left is not empty. Otherwise, we start to scan ahead, so in empty space it will go straight.

Finally, there is a check that this.dir and start match on each pass of the loop so that it does not get stuck in time when there is no place for the creature to go because of walls or other creatures and it cannot find an empty cell.

This small world shows creatures moving along the walls .:

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


More life situation


To make life in our little world more interesting, add the concept of food and reproduction. Every living creature has a new property, energy, which decreases when performing actions, and increases when eating food. When a creature has enough energy, it can multiply, creating a new creature of the same type. To simplify calculations, our creatures multiply by themselves.

If creatures only move and eat each other, the world will soon succumb to increasing entropy, it will run out of energy and it will turn into a desert. To prevent this ending (or delaying), we add plants to it. They do not move. They just do photosynthesis and grow (gain energy), and multiply.

For this to work, we need peace with another letAct method. We could just replace the World prototype method, but I’m used to our simulation of creatures walking along walls and wouldn’t want to destroy it.

One solution is to use inheritance. We are creating a new constructor, LifelikeWorld, whose prototype is based on the World prototype, but overrides the letAct method. The new letAct passes the work of committing actions to different functions stored in the actionTypes object.

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


The new letAct method checks if at least some action has been passed, then if there is a function that processes it, and at the end whether this function returns true, indicating that the action was completed successfully. Note the use of call to give functions access to the world object through this.

If the action did not work for any reason, the default action for the creature will be waiting. He loses 0.2 units of energy, and when his energy level drops below zero, he dies and disappears from the grid.

Action handlers


The simplest action is growth, it is used by plants. When an action object of the type {type: "grow"} is returned, the following handler method will be called:

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


Growth is always successful and adds half the unit to the energy level of the plant.

The movement is more complex.

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


This action first checks, using the checkDestination method previously declared, whether the action provides a valid direction. If not, or in that direction is not an empty area, or the creature lacks energy - move returns false, indicating that the action did not take place. Otherwise, he moves the creature and subtracts energy.

In addition to movement, creatures can 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; }; 


Eating another creature also requires the provision of a valid direction cell. In this case, the cell must contain something with energy, such as a creature (but not a wall, they cannot be eaten). If this is confirmed, the energy eaten goes to the feeder, and the victim is removed from the grid.

Finally, we allow creatures to multiply.

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


Reproduction takes up twice the energy that a newborn has. Therefore, we create a hypothetical offspring using elementFromChar on the original creature. As soon as we have a son, we can find out his energy level and check whether the parent has enough energy to give birth to him. We also need a valid direction cell.

If everything is in order, the offspring is placed on the grid (and ceases to be hypothetical), and energy is wasted.

We inhabit the world


Now we have a foundation for simulating creatures that look more like real ones. We could put beings from the old world into the new world, but they would simply die, since they do not have the energy property. Let's make new ones. First, we write a plant that, in fact, is a fairly simple form of life.

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


Plants start with a random level of energy from 3 to 7, so that they do not multiply all in one move. When the plant reaches energy 15, and next is an empty cell - it multiplies into it. If it cannot multiply, then it simply grows until it reaches the energy of 20.

Now we define the plant eater.

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


For plants we will use the symbol * - that which the creature will look for in search of food.

Breathe life


And now we have enough elements for the new world. Imagine the next map as a grassy valley where a herd of herbivores grazes, several boulders lie and lush vegetation blooms.

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


Most of the time, plants multiply and grow, but then an abundance of food leads to an explosive growth of the herbivore population, which eat up almost all the vegetation, which leads to a massive extinction from starvation. Sometimes the ecosystem is restored and a new cycle begins. In other cases, one of the species dies out. If herbivores, then the whole space is filled with plants. If the plants are the remaining creatures, they die of starvation, and the valley turns into an uninhabited wasteland. Oh, the cruelty of nature ...

Exercises


Artificial idiot

It is sad when the inhabitants of our world die out in a few minutes. To deal with this, we can try to create a smarter plant eater.

Our herbivores have several obvious problems. First, they are greedy - they eat every plant they find until they completely destroy all the vegetation. Secondly, their random movement (remember that the view.find method returns a random direction) causes them to dangle inefficiently and die of hunger if there are no plants nearby. Finally, they multiply too fast, which makes cycles from abundance to hunger too fast.

Write a new type of creature that tries to cope with one or more problems and replace it with the old type PlantEater in the world of the valley. Follow them. Make the necessary adjustments.

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


Predators

In any serious ecosystem, the food chain is longer than one link. Write another creature that survives by eating herbivores. You will notice that stability is even harder to achieve when cycles occur at different levels. Try to find a strategy that will allow the ecosystem to work smoothly for some time.

Increasing the world can help in this. Then local demographic explosions or downsizing are less likely to completely destroy the population, and there is room for a relatively large population of victims that can support a small population of predators.

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

Source: https://habr.com/ru/post/241776/


All Articles