Pull to refresh

Выразительный JavaScript: проект: Электронная жизнь

Reading time 18 min
Views 47K
Original author: Marijn Haverbeke

Содержание




Вопрос о том, могут ли машины думать так же уместен, как вопрос о том, могут ли подводные лодки плавать.

Эдсгер Дейкстра, Угрозы вычислительной науке


В главах-проектах я перестану закидывать вас теорией, и буду работать вместе с вами над программами. Теория незаменима при обучении программированию, но она должна сопровождаться чтением и пониманием нетривиальных программ.

Наш проект – постройка виртуальной экосистемы, небольшого мира, населённого существами, которые двигаются и борются за выживание.

Определение


Чтобы задача стала выполнимой, мы кардинально упростим концепцию мира. А именно – мир будет двумерной сеткой, где каждая сущность занимает одну клетку. На каждом ходу существа получат возможность выполнить какое-либо действие.

Таким образом, мы порубим время и пространство на единицы фиксированного размера: клетки для пространства и ходы для времени. Конечно, это грубое и неаккуратное приближение. Но наша симуляция должна быть развлекательной, а не аккуратной, поэтому мы свободно «срезаем углы».

Определить мир мы можем при помощи плана – массива строк, который раскладывает мировую сетку, используя один символ на клетку.

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


Символ “#” обозначает стены и камни, “o” – существо. Пробелы – пустое пространство.

План можно использовать для создания объекта мира. Он следит за размером и содержимым мира. У него есть метод toString, который преобразовывает мир в выводимую строчку (такую, как план, на котором он основан), чтобы мы могли наблюдать за происходящим внутри него. У объект мира есть метод turn (ход), позволяющий всем существам сделать один ход и обновляющий состояние мира в соответствии с их действиями.

Изображаем пространство


У сетки, моделирующей мир, заданы ширина и высота. Клетки определяются координатами x и y. Мы используем простой тип 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);
};


Потом нам нужен тип объекта, моделирующий саму сетку. Сетка – часть мира, но мы делаем из неё отдельный объект (который будет свойством мирового объекта), чтобы не усложнять мировой объект. Мир должен загружать себя вещами, относящимися к миру, а сетка – вещами, относящимися к сетке.

Для хранения сетки значений у нас есть несколько вариантов. Можно использовать массив из массивов-строк, и использовать двухступенчатый доступ к свойствам:

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


Или мы можем взять один массив, размера width × height, и решить, что элемент (x, y) находится в позиции 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


Поскольку доступ будет завёрнут в методах объекта сетки, внешнему коду всё равно, какой подход будет выбран. Я выбрал второй, потому что с ним проще создавать массив. При вызове конструктора Array с одним числом в качестве аргумента он создаёт новый пустой массив заданной длины.

Следующий код объявляет объект Grid (сетка) с основными методами:

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


Программный интерфейс существ


Перед тем, как заняться конструктором мира World, нам надо определиться с объектами существ, населяющих его. Я упомянул, что мир будет спрашивать существ, какие они хотят произвести действия. Работать это будет так: у каждого объекта существа есть метод act, который при вызове возвращает действие action. Это действие – объект со свойством `type`, называющим тип действия, которое хочет совершить существо, к примеру `»move"`. Действие может содержать дополнительную информацию — такую, как направление движения.

Существа ужасно близоруки и видят только непосредственно прилегающие к ним клетки. Но и это может пригодиться при выборе действий. При вызове метода act ему даётся объект view, который позволяет существу изучить прилегающую местность. Мы называем восемь соседних клеток их направлениями по компасу: “n” на север, “ne” на северо-восток, и т.п. Вот какой объект будет использоваться для преобразования из названий направлений в смещения по координатам:

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 есть метод look, который принимает направление и возвращает символ, к примеру "#", если там стена, или пробел, если там ничего нет. Объект также предоставляет удобные методы find и findAll. Оба принимают один из символов, представляющих вещи на карте, как аргумент. Первый возвращает направление, в котором этот предмет можно найти рядом с существом, или же null, если такого предмета рядом нет. Второй возвращает массив со всеми возможными направлениями, где найден такой предмет. Например, существо слева от стены (на западе) получит [«ne», «e», «se»] при вызове findAll у объекта view с аргументом “#”.

Вот простое тупое существо, которое просто идёт, пока не врезается в препятствие, а затем отскакивает в случайном направлении.

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


Вспомогательная функция randomElement просто выбирает случайный элемент массива, используя Math.random и немного арифметики, чтобы получить случайный индекс. Мы и дальше будем использовать случайность, так как она – полезная штука в симуляциях.

Чтобы выбрать случайное направление движения, конструктор BouncingCritter вызывает randomElement на массиве с названиями направлений. Также мы могли бы использовать Object.keys, чтобы получить этот массив от определённого нами объекта directions, однако такой подход не даёт нам гарантий по поводу порядка полученного списка. В большинстве случаев современные интерптераторы JS вернут свойства в том порядке, в котором они были определены, но они не обязаны так делать.

Конструкция “|| «s»” в методе act нужна, чтобы this.direction не получил null, в случае если существо забилось в угол без свободного пространства вокруг – например, окружено другими существами.

Мировой объект


Теперь можно приступать к мировому объекту World. Конструктор принимает план (массив строк, представляющих сетку мира) и объект legend. Это объект, сообщающий, что означает каждый из символов карты. В нём есть конструктор для каждого символа – кроме пробела, который ссылается на null (представляющий пустое пространство).

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


В elementFromChar мы сначала создаём экземпляр нужного типа, находя конструктор символа и применяя к нему new. Потом добавляем свойство originChar, чтобы было просто выяснить, из какого символа элемент был создан изначально.

Нам понадобится это свойство originChar при изготовлении мирового метода toString. Метод строит карту в виде строки из текущего состояния мира, проходя двумерным циклом по клеткам сетки.

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 – простой объект. Используется для занятия места и не имеет метода act.

function Wall() {}


Проверяя объект World, создав экземпляр с использованием плана, заданного в начале главы, и затем вызвав его метод toString, мы получим очень похожую на этот план строку.

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


this и его область видимости

В конструкторе World есть вызов forEach. Хочу отметить, что внутри функции, передаваемой в forEach, мы уже не находимся непосредственно в области видимости конструктора. Каждый вызов функции получает своё пространство имён, поэтому this внутри неё уже не ссылается на создаваемый объект, на который ссылается this снаружи функции. И вообще, если функция вызывается не как метод, this будет относиться к глобальному объекту.

Значит, мы не можем писать this.grid для доступа к сетке изнутри цикла. Вместо этого внешняя функция создаёт локальную переменную grid, через которую внутренняя функция получает доступ к сетке.

Это промах в дизайне JavaScript. К счастью, в следующей версии есть решение этой проблемы. А пока есть пути обхода. Обычно пишут

var self = this


и после этого работают с переменной self.

Другое решение – использовать метод bind, который позволяет привязаться к конкретному объекту this.

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


Функция, передаваемая в map – результат привязки вызова, и посему её this привязан к первому аргументу, переданному в bind, то есть переменной this внешней функции (в которой содержится объект test).

Большинство стандартных методов высшего порядка у массивов, таких как forEach и map, принимают необязательный второй аргумент, который тоже можно использовать для передачи this при вызовах итерационной функции. Вы могли бы написать предыдущий пример чуть проще:

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


Это работает только с теми функциями высшего порядка, у которых есть такой контекстный параметр. Если нет – приходится использовать другие упомянутые подходы.

В нашей собственной функции высшего порядка мы можем включить поддержку контекстного параметра, используя метод call для вызова функции, переданной в качестве аргумента. К примеру, вот вам метод forEach для нашего типа Grid, вызывающий заданную функцию для каждого элемента решётки, который не равен null или 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));
    }
  }
};


Оживляем мир


Следующий шаг – создание метода turn (шаг) для мирового объекта, дающего существам возможность действовать. Он будет обходить сетку методом forEach, и искать объекты, у которых есть метод act. Найдя объект, turn вызывает этот метод, получая объект action и производит это действие, если оно допустимо. Пока мы понимаем только действие “move”.

Есть одна возможная проблема. Можете увидеть, какая? Если мы позволим существам двигаться по мере того, как мы их перебираем, они могут перейти на клетку, которую мы ещё не обработали, и тогда мы позволим им сдвинуться ещё раз, когда очередь дойдёт до этой клетки. Таким образом, нам надо хранить массив существ, которые уже сделали свой шаг, и игнорировать их при повторном проходе.

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


Второй параметр метода forEach используется для доступа к правильной переменной this во внутренней функции. Метод letAct содержит логику, которая позволяет существам двигаться.

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


Сначала мы просто просим существо действовать, передавая ему объект view, который знает про мир и текущее положение существа в мире (мы скоро зададим View). Метод act возвращает какое-либо действие.

Если тип действия не “move”, оно игнорируется. Если “move”, и если у него есть свойство direction, ссылающееся на допустимое направление, и если клетка в этом направлении пустует (null), мы назначаем клетке, где только что было существо, null, и сохраняем существо в клетке назначения.

Заметьте, что letAct заботится об игнорировании неправильных входных данных. Он не предполагает по умолчанию, что направление допустимо, или, что свойство типа имеет смысл. Такого рода защитное программирование в некоторых ситуациях имеет смысл. В основном это делается для проверки входных данных, приходящих от источников, которые вы не контролируете (ввод пользователя или чтение файла), но оно также полезно для изолирования подсистем друг от друга. В нашем случае его цель – учесть, что существа могут быть запрограммированы неаккуратно. Им не надо проверять, имеют ли их намерения смысл. Они просто запрашивают возможность действия, а мир сам решает, разрешать ли его.

Эти два метода не принадлежат к внешнему интерфейсу мирового объекта. Они являются деталями внутренней реализации. Некоторые языки предусматривают способы объявлять определённые методы и свойства «приватными», и выдавать ошибку при попытке их использования снаружи объекта. JavaScript не предусматривает такого, так что вам придётся полагаться на другие способы сообщить о том, что является частью интерфейса объекта. Иногда помогает использование схемы именования свойств для различения внутренних и внешних, например, с особыми приставками к именам внутренних, типа подчёркивания (_). Это облегчит выявление случайного использования свойств, не являющихся частью интерфейса.

А пропущенная часть, тип View, выглядит следующим образом:

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


Метод look вычисляет координаты, на которые мы пытаемся посмотреть. Если они находятся внутри сетки, то получает символ, соответствующий элементу, находящемуся там. Для координат снаружи сетки look просто притворяется, что там стена – если вы зададите мир без окружающих стен, существа не смогут сойти с края.

Оно двигается


Мы создали экземпляр мирового объекта. Теперь, когда все необходимые методы готовы, у нас должно получиться заставить его двигаться.

for (var i = 0; i < 5; i++) {
  world.turn();
  console.log(world.toString());
}
// → … пять ходов


Просто выводить пять копий карты – не очень удобный способ наблюдения за миром. Поэтому в песочнице для книги (или в файлах для скачивания) есть волшебная функция animateWorld, которая показывает мир как анимацию на экране, делая по три шага в секунду, пока вы не нажмёте стоп.

animateWorld(world);
// → … заработало!


Реализация animateWorld пока останется тайной, но после прочтения следующих глав книги, обсуждающих интеграцию JavaScript в браузеры, она уже не будет выглядеть так загадочно.

Больше форм жизни


Одна из интересных ситуаций, происходящих в мире, случается, когда два существа отскакивают друг от друга. Можете придумать другую интересную форму взаимодействий?

Я придумал существо, двигающееся по стенке. Оно держит свою левую руку (лапу, щупальце, что угодно) на стене и двигается вдоль неё. Это, как оказалось, не так-то просто запрограммировать.

Нам нужно будет вычислять, используя направления в пространстве. Так как направления заданы набором строк, нам надо задать свою операцию dirPlus для подсчёта относительных направлений. dirPlus(«n», 1) означает поворот по часовой на 45 градусов с севера, что приводит к “ne”. dirPlus(«s», -2) означает поворот против часовой с юга, то есть на восток.

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


Метод act только сканирует окружение существа, начиная с левой стороны и дальше по часовой, пока не находит пустую клетку. Затем он двигается в направлении этой клетки.

Усложняет ситуацию то, что существо может оказаться вдали от стен на пустом пространстве — либо обходя другое существо, либо изначально оказавшись там. Если мы оставим описанный алгоритм, несчастное существо будет каждый ход поворачивать налево, и бегать по кругу.

Так что есть ещё одна проверка через if, что сканирование нужно начинать, если существо только что прошло мимо какого-либо препятствия. То есть, если пространство сзади и слева не пустое. В противном случае сканировать начинаем впереди, поэтому в пустом пространстве он будет идти прямо.

И наконец, есть проверка на совпадение this.dir и start на каждом проходе цикла, чтобы он не зациклился, когда существу некуда идти из-за стен или других существ, и оно не может найти пустую клетку.

Этот небольшой мир показывает существ, двигающихся по стенам:

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


Более жизненная ситуация


Чтобы сделать жизнь в нашем мирке более интересной, добавим понятия еды и размножения. У каждого живого существа появляется новое свойство, energy (энергия), которая уменьшается при совершении действий, и увеличивается при поедании еды. Когда у существа достаточно энергии, он может размножаться, создавая новое существо того же типа. Для упрощения расчётов наши существа размножаются сами по себе.

Если существа только двигаются и едят друг друга, мир вскоре поддастся возрастающей энтропии, в нём закончится энергия и он превратится в пустыню. Для предотвращения этого финала (или оттягивания), мы добавляем в него растения. Они не двигаются. Они просто занимаются фотосинтезом и растут (нарабатывают энергию), и размножаются.

Чтобы это заработало, нам нужен мир с другим методом letAct. Мы могли бы просто заменить метод прототипа World, но я привык к нашей симуляции ходящих по стенам существ и не хотел бы её разрушать.

Одно из решений – использовать наследование. Мы создаём новый конструктор, LifelikeWorld, чей прототип основан на прототипе World, но переопределяет метод letAct. Новый letAct передаёт работу по совершению действий в разные функции, хранящиеся в объекте 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);
  }
};


Новый метод letAct проверяет, было ли передано хоть какое-то действие, затем – есть ли функция, обрабатывающая его, и в конце – возвращает ли эта функция true, показывая, что действие выполнено успешно. Обратите внимание на использование call, чтобы дать функции доступ к мировому объекту через this.

Если действие по какой-либо причине не сработало, действием по умолчанию для существа будет ожидание. Он теряет 0.2 единицы энергии, а когда его уровень энергии падает до нуля или ниже, он умирает и исчезает с сетки.

Обработчики действий


Самое простое действие – рост, его используют растения. Когда возвращается объект action типа {type: «grow»}, будет вызван следующий метод-обработчик:

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


Рост всегда успешен и добавляет половину единицы к энергетическому уровню растения.

Движение получается более сложным.

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


Это действие вначале проверяет, используя метод checkDestination, объявленный ранее, предоставляет ли действие допустимое направление. Если нет, или же в том направлении не пустой участок, или же у существа недостаёт энергии – move возвращает false, показывая, что действие не состоялось. В ином случае он двигает существо и вычитает энергию.

Кроме движения, существа могут есть.

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


Поедание другого существа также требует предоставления допустимой клетки направления. В этом случае клетка должна содержать что-либо с энергией, например существо (но не стену, их есть нельзя). Если это подтверждается, энергия съеденного переходит к едоку, а жертва удаляется с сетки.

И наконец, мы позволяем существам размножаться.

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


Размножение отнимает в два раза больше энергии, чем есть у новорожденного. Поэтому мы создаём гипотетического отпрыска, используя elementFromChar на оригинальном существе. Как только у нас есть отпрыск, мы можем выяснить его энергетический уровень и проверить, есть ли у родителя достаточно энергии, чтобы родить его. Также нам потребуется допустимая клетка направления.

Если всё в порядке, отпрыск помещается на сетку (и перестаёт быть гипотетическим), а энергия тратится.

Населяем мир


Теперь у нас есть основа для симуляции существ, больше похожих на настоящие. Мы могли бы поместить в новый мир существ из старого, но они бы просто умерли, так как у них нет свойства energy. Давайте сделаем новых. Сначала напишем растение, которое, по сути, довольно простая форма жизни.

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


Растения начинают со случайного уровня энергии от 3 до 7, чтобы они не размножались все в один ход. Когда растение достигает энергии 15, а рядом есть пустая клетка – оно размножается в неё. Если оно не может размножится, то просто растёт, пока не достигнет энергии 20.

Теперь определим поедателя растений.

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


Для растений будем использовать символ * — то, что будет искать существо в поисках еды.

Вдохнём жизнь


И теперь у нас есть достаточно элементов для нового мира. Представьте следующую карту как травянистую долину, где пасётся стадо травоядных, лежат несколько валунов и цветёт буйная растительность.

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


Большую часть времени растения размножаются и разрастаются, но затем изобилие еды приводит к взрывному росту популяции травоядных, которые съедают почти всю растительность, что приводит к массовому вымиранию от голода. Иногда экосистема восстанавливается и начинается новый цикл. В других случаях какой-то из видов вымирает. Если травоядные, тогда всё пространство заполняется растениями. Если растения – оставшиеся существа умирают от голода, и долина превращается в необитаемую пустошь. О, жестокость природы…

Упражнения


Искусственный идиот

Грустно, когда жители нашего мира вымирают за несколько минут. Чтобы справиться с этим, мы можем попробовать создать более умного поедателя растений.

У наших травоядных есть несколько очевидных проблем. Во-первых, они жадные — поедают каждое растение, которое находят, пока полностью не уничтожат всю растительность. Во-вторых, их случайное движение (вспомните, что метод view.find возвращает случайное направление) заставляет их болтаться неэффективно и помирать с голоду, если рядом не окажется растений. И наконец, они слишком быстро размножаются, что делает циклы от изобилия к голоду слишком быстрыми.

Напишите новый тип существа, который старается справиться с одним или несколькими проблемами и замените им старый тип PlantEater в мире долины. Последите за ними. Выполните необходимые подстройки.

// Ваш код
function SmartPlantEater() {}

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


Хищники

В любой серьёзной экосистеме пищевая цепочка длиннее одного звена. Напишите ещё одно существо, которое выживает, поедая травоядных. Вы заметите, что стабильности ещё труднее достичь, когда циклы происходят на разных уровнях. Попытайтесь найти стратегию, которая позволит экосистеме работать плавно некоторое время.

Увеличение мира может помочь в этом. Тогда локальные демографические взрывы или уменьшение численности имеют меньше шансов полностью изничтожить популяцию, и есть место для относительно большой популяции жертв, которая может поддерживать небольшую популяцию хищников.

// Ваш код тут
function Tiger() {}

animateWorld(new LifelikeWorld(
  ["####################################################",
   "#                 ####         ****              ###",
   "#   *  @  ##                 ########       OO    ##",
   "#   *    ##        O O                 ****       *#",
   "#       ##*                        ##########     *#",
   "#      ##***  *         ****                     **#",
   "#* **  #  *  ***      #########                  **#",
   "#* **  #      *               #   *              **#",
   "#     ##              #   O   #  ***          ######",
   "#*            @       #       #   *        O  #    #",
   "#*                    #  ######                 ** #",
   "###          ****          ***                  ** #",
   "#       O                        @         O       #",
   "#   *     ##  ##  ##  ##               ###      *  #",
   "#   **         #              *       #####  O     #",
   "##  **  O   O  #  #    ***  ***        ###      ** #",
   "###               #   *****                    ****#",
   "####################################################"],
  {"#": Wall,
   "@": Tiger,
   "O": SmartPlantEater, // из предыдущего упражнения
   "*": Plant}
));
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+26
Comments 3
Comments Comments 3

Articles