Por que eu devo ler este artigo:A inteligência artificial é uma área de muita necessidade, principalmente no universo de jogos quando os mesmos precisam ter personagens inteligentes que evoluam através das experiências no próprio jogo. Esse tipo de inteligência é traduzido nas linguagens de programação por meio de técnicas que permitam aos algoritmos salvar uma grande quantidade de dados e reusá-los para aprimorar o comportamento dos objetos.

Neste artigo trataremos de implementar uma simulação de mundo inteligente, onde vivem criaturas que, por si sós, se alimentam, reproduzem, andam e morrem. Os códigos apresentados também poderão ser usados para implementações com jogos (RPGs, arcade, estilo fazenda, etc.), animações gráficas diversas, dentre outras.

O conceito de Inteligência Artificial é comumente definido como a inteligência exibida por máquinas ou softwares que se equipara ao comportamento racional humano. Baseada em conceitos como dedução, raciocínio e resolução automática de problemas, a IA e seus conceitos nunca foram tão buscados, explorados e utilizados pela humanidade como hoje. Alvo de inúmeras críticas e ceticismo por uma parte das pessoas que a estuda, a IA hoje está presente em jogos (RPGs principalmente, em vista da necessidade de ter personagens que evoluam e desenvolvam habilidades em detrimento dos aprendizados e experiências que tiveram), sistemas de animação gráfica, dentre outros.

Quando se trata de implementar IA em sistemas usando uma linguagem de programação comum (como Java, C# ou JavaScript) muitos desenvolvedores desconhecem os passos simples que podem levar a isso, ou até mesmo consideram que a inclusão de bibliotecas avançadas de scripts se faz necessária para lidar com isso.

Este artigo terá como objetivo a construção de um ecossistema virtual, um pequeno mundo que será populado dinamicamente e artificialmente por criaturas que se movimentam e lutam entre si por sobrevivência. Será basicamente uma implementação de um componente inteligente que pode ser usado em cenários reais de jogos, animações gráficas, dentre outros que necessitem que determinados personagens assumam características de inteligência artificial.

Cenário

O cenário de desenvolvimento se restringe a radicalmente simplificar o conceito de mundo simulado. Em teoria, um mundo seria uma grid bidimensional onde cada entidade ocupa uma célula inteira do quadrado, imaginando que esse plano fosse representado por um quadrado de dimensões {x, y} que seria dividido em células – menores partes possíveis – e, por sua vez, teriam dimensões {x/a, y/b}, onde a e b representam os graus de divisão máximos para os eixos horizontais e verticais do plano cartesiano.

Vamos chamar as entidades de criaturas. Há cada rodada, as criaturas recebem uma nova chance de executar alguma ação. Assim, cortamos ambos tempo e espaço em unidades com tamanho fixo: quadrados para o espaço e bolas para o tempo. Obviamente, essa não é uma aproximação real e apurada de como isso funciona em algoritmos mais complexos do tipo, mas para os propósitos deste artigo são mais que o suficiente, além de estabelecerem um paralelo mais didático com o desenvolvedor.

Em suma, iremos definir um objeto mundo com um plano, um vetor de strings que cria a grid do mesmo usando um caractere por quadrado. Veja na Listagem 1 o código que representa a criação da referida variável vetor que usaremos para representar este plano.

var plano = ["@@@@@@@@@@@@@@@@@@@@@@@@@@@@",
    "@@      @      ()     @ @",
    "@                          @",
    "@ @        @@@@            @",
    "@@         @  @    @@      @",
    "@@@          @@    @       @",
    "@ @@@        @             @",
    "@ @          @@@           @",
    "@  @@ ()                   @",
    "@ () @        () @@@       @",
    "@                 @        @",
    "@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
];
Listagem 1. Representação inicial do modelo de plano bidimensional

Perceba que a disposição dos elementos na tela se assemelha à que temos em telas de jogos que geram planos de fundos aleatórios como Pack Man, Super Mário, dentro outros do estilo. No modelo, os caracteres “@” representam as paredes e pedras geradas no meio do cenário, e o caractere “()” representa as criaturas (quatro, no total). Os espaços são simplesmente espaços comuns como você deve ter percebido. O leitor deve, inclusive, tomar cuidado se for usar essa estrutura em páginas HTML, uma vez que espaços em branco não são reconhecidos pelo browser, portanto devendo usar os caracteres de espace respectivos.

Esse array pode ser usado para criar o mundo “plano” em 2D que imaginamos. Tal objeto contém informações referentes ao tamanho e conteúdo do nosso mundo. Além disso, o objeto contém um método toString() que converte a representação matricial do mesmo em uma string que pode ser impressa, e que nos permite analisar o que está acontecendo em caso de querermos debugar o código. O objeto também tem uma função chamada turn(), que permite aos objetos de criatura atualizar (de dentro dele) por uma rodada e atualizar o objeto mundo como um todo, refletindo assim suas ações.

Representando o espaço

A grid que modela o objeto mundo tem largura e altura fixas. Os quadrados são identificados por suas respectivas coordenadas x e y. Para representar o espaço, criaremos uma classe que simulará basicamente o salvamento das duas informações dos eixos base, e a chamaremos de Vector. Certifique-se de criar a estrutura representada na Listagem 2 dentro do nosso arquivo global.js.

function Vector(x, y) {
   this.x = x;
   this.y = y;
}
Vector.prototype.maisUm = function(outro) {
   return new Vector(this.x + outro.x, this.y + outro.y);
};
Listagem 2. Código de criação do objeto Vector no nosso modelo

A única novidade nessa listagem compreende o uso dos prototypes no JavaScript, que são estruturas que promovem a herança da Orientação a Objetos por intermédio da criação de classes via funções. Perceba que na linha 6 simplesmente criamos mais um objeto desse tipo, com as informações do objeto Vector recebido como parâmetro, ou seja, essa definição de função basicamente cria cópias do objeto recebido.

Nota: Esse tipo de abordagem é muito usado por ferramentas de construção gráfica e/ou jogos como o Unity 3D e o CocoaTouch. Eles usam um objeto Vector com o mesmo conceito para manipular o universo bidimensional. Caso desejássemos migrar para um universo tridimensional, bastaria incluir uma nova propriedade à classe referente ao plano excedente.

Agora precisamos criar um tipo de objeto que molde a grid por si só. Uma grid é parte da representação física do nosso objeto mundo, mas iremos implementá-la em um objeto separado (que será uma propriedade do objeto mundo, por meio de uma composição) para manter o objeto mundo simples, aplicando assim a divisão de responsabilidades e desacoplamento de código.

Para salvar os valores da grid, temos algumas opções:

  1. Podemos usar um vetor de linhas de vetores e usar duas propriedades para acessar um quadrado em específico, algo mais ou menos assim:
    var grid = [["superior esquerda", "superior meio", "superior direita"],
      ["inferior esquerda", "inferior meio", "inferior direita"]];
    console.log(grid[1][2]);
  2. O resultado da execução imprimiria o valor “inferior direita” no Console.

    Ou podemos usar um vetor simples, com tamanho definido na forma de largura x altura, e decidir qual elemento em {x, y} é encontrado na posição x + (y * largura) do vetor:

    var grid = ["superior esquerda", "superior meio", "superior direita"
      "inferior esquerda", "inferior meio", "inferior direita"];
    console.log(grid[2 + (1 * 3)]);

O resultado da execução imprimiria o valor “inferior direita” no Console.

Uma vez que o acesso a este vetor estará encapsulado em métodos do objeto do tipo grid, não importa para o código externo que abordagem escolheremos. Vamos usar a segunda opção porque ela facilita muito o processo de criação do vetor. Quando chamarmos o construtor de Array com um simples número como argumento, ele criará um novo array vazio com o tamanho fornecido. Para isso, vamos criar então o código que define o objeto Grid, somente com alguns métodos básicos. Crie um novo arquivo grid.js e adicione o conteúdo da Listagem 3 ao mesmo.

function Grid(largura, altura) {
   this.espaco = new Array(largura * altura);
   this.largura = largura;
   this.altura = altura;
}
Grid.prototype.isDentro = function(vetor) {
   return vetor.x >= 0 && vetor.x < this.largura &&
       vetor.y >= 0 && vetor.y < this.altura;
};
Grid.prototype.get = function(vetor) {
   return this.espaco[vetor.x + this.largura * vetor.y];
};
Grid.prototype.set = function(vetor, value) {
   this.espaco[vetor.x + this.largura * vetor.y] = value;
};
Listagem 3. Código de criação do objeto Grid no nosso modelo

Perceba que a semelhança nos construtores de Grid e Vector se definem ao recebimento das dimensões, neste caso a altura e largura. Logo na linha 2 estamos criando um novo objeto Array (classe interna da própria biblioteca do JavaScript) passando como parâmetro o valor calculado da área total da grid (largura x altura). Após isso, definimos três novos métodos para esse objeto:

  • isDentro: recebe o objeto Vector como parâmetro e verifica se a grid está dentro das dimensões do vetor.
  • get: recebe o Vector por parâmetro e retorna a posição exata no Array de espaço.
  • set: recebe o Vector e o novo valor a ser configurado naquela posição x do Array de espaço.

Para testar essas configurações iniciais, podemos criar uma página HTML de testes e dentro dela importar os nossos arquivos JavaScript, além de criar uma tag de script básica para efetuar as chamadas e imprimir no Console do browser. Para isso, crie um novo arquivo chamado teste.html dentro da estrutura de diretórios que definimos para os projetos de teste, e adicione o conteúdo da Listagem 4 ao mesmo.

<html>
  <head>
    <title>
          Página de testes: Simulações artificiais
    </title>
    <script type="text/javascript" src="global.js"></script>
    <script type="text/javascript" src="grid.js"></script>
    <script type="text/javascript">
          var grid = new Grid(5, 5);
          console.log(grid.get(new Vector(1, 1)));
          
          grid.set(new Vector(1, 1), " X ");
          console.log(grid.get(new Vector(1, 1)));
    </script>
  </head>
  
  <body>
    
  </body>
</html>
Listagem 4. Código de criação da página de testes

Perceba que no teste (bem básico, sem a inclusão de quaisquer bibliotecas de teste unitários JavaScript) estamos apenas verificando ambas as situações, em detrimento do código já produzido nas listagens anteriores. Perceba também que as chamadas aos arquivos de código JavaScript (linhas 6 e 7) estão apontando para o mesmo diretório onde se encontra o arquivo HTML, mas você pode ficar à vontade para decidir onde quer colocá-los, desde que se lembre de modificar as referências aos caminhos relativos.

Agora é só executar a página no browser de sua preferência e abrir o Console de logs do mesmo. A maioria deles está disponível através do atalho de teclado F12, mas é possível consultar a documentação oficial do mesmo caso tenha dúvidas de como encontrar. Neste artigo usaremos o Google Chrome, por razões de simplicidade e usabilidade. Ao acessar a opção, verá logo de cara mensagens semelhantes às impressas na Figura 1. Os textos undefined (que será impresso em virtude da ausência de um objeto em memória na posição estabelecida pela linha 10) e “X” (que será exibido em função da existência desse texto no modelo que criamos de mundo, por causa do valor referenciado no objeto Vector da linha 13) são os resultados finais do teste.

img
Figura 1. Resultado dos testes sobre o objeto mundo

Programando a interface das criaturas

Antes de começarmos no construtor da classe Mundo, devemos entender melhor como os objetos de criatura irão se comportar dentro desse mundo. O objeto mundo irá solicitar aos objetos criaturas que tipo de ações eles irão executar, funcionando da seguinte forma: cada objeto criatura tem o seu próprio método agir() que, quando chamado, retorna uma ação. Esta é um objeto com uma propriedade tipo, que nomeia o tipo da ação que a criatura quer fazer, por exemplo “mover”. A ação também poderá conter informações extra, tais como a direção que a criatura quer se mover (x, y), etc.

As criaturas, por sua vez, só poderão enxergar os quadrados diretamente ao redor delas na grid. Mas até essa “visão” limitada pode ser útil quando tiver de decidir que ação tomar. Quando o método agir() for chamado, ele receberá um objeto de visão (view) que permitirá à criatura inspecionar a sua volta. Vamos nomear os oito quadrados que estão em volta dela de “direções da bússola”: n para norte, ne para nordeste, e assim por diante. Veja na Listagem 5 o objeto que usaremos para mapear desde os nomes das coordenadas até as configurações da nossa bússola imaginária.

var directions = {
" n ": new Vector ( 0 , -1) , // Norte
" ne ": new Vector ( 1 , -1) , // Nordeste
" l ": new Vector ( 1 , 0) , // Leste
" sl ": new Vector ( 1 , 1) , // Sudeste
" s ": new Vector ( 0 , 1) , // Sul
" so ": new Vector ( -1 , 1) , // Sudoeste
" o ": new Vector ( -1 , 0) , // Oeste
" no ": new Vector ( -1 , -1) // Noroeste
};
Listagem 5. Objeto responsável por guardar os valores de direção

Na listagem, cada coordenada geográfica é representada por um objeto Vector que, por sua vez, está associado a uma String que o identifica (n, ne, l, etc.). O objeto view terá um método observar(), que toma uma direção e retorna um caractere, por exemplo \@ quando existe uma parede naquela direção, ou “ ” (espaço em branco) quando não há nada na referida direção. O objeto também fornece os métodos utilitários find e findAll, herdados da herança implícita. Ambos recebem um mapa de caracteres como argumento. O primeiro retorna à direção em que o caractere pode ser encontrado próximo à criatura ou retorna null se não encontrar nada. O segundo retorna um array contendo todas as direções com aquele caractere. Por exemplo, uma criatura localizada à direita (oeste) da parede receberá (ne, l, sl) quando chamar o método findAll no seu objeto view com o caractere \@ como argumento.

Vejamos na Listagem 6 um exemplo bem simples de uma criatura não inteligente que segue seus próprios passos até que ela atinja um obstáculo e, em seguida, salta para fora em uma direção aleatória.

function elementoRandomico(array) {
 return array[Math.floor(Math.random() * array.length)];
}

var direcoes = "n ne l sl s so o no".split(" ");

function RedondezasCriatura() {
 this.direcao = elementoRandomico(direcoes) ;
};
RedondezasCriatura.prototype.agir = function(view) {
 if (view.observar(this.direcao) != " ")
   this.direcao = view.find(" ") || "s";
 return { type: "mover", direcao: this.direcao };
};
Listagem 6. Objeto responsável por guardar os valores de direção

Observe que a função elementoRandomico() pega um número aleatório do array e, usando a função da API Math do próprio JavaScript (linha 2) mais algumas operações aritméticas, o transforma em um índice randômico. Usaremos isso novamente mais à frente porque aleatoriedade pode ser muito útil em simulações.

Para escolher uma direção aleatória, o construtor de RedondezasCriatura chama a função elementoRandomico() em uma matriz de nomes de direção. Também poderíamos ter usado o vetor Object.keys para obter essa matriz do objeto direcoes que definimos anteriormente, mas isso não nos fornece nenhuma garantia sobre a ordem em que as propriedades são listadas. Na maioria das situações, os motores modernos JavaScript retornarão as propriedades na ordem em que foram definidas, mas eles não são necessários para essa finalidade.

O "|| s" no método agir() está lá para impedir que this.direcao receba o valor null se a criatura estiver, de alguma forma, presa sem nenhum espaço vazio em torno dela (por exemplo, quando estiver presa em um canto do objeto mundo e cercada por outras criaturas).

Criando o objeto Mundo

Agora podemos começar a desenvolver o principal objeto do nosso aplicativo, o objeto Mundo. O seu construtor usará um plano e uma legenda como argumentos. Uma legenda é um objeto que nos diz o que cada caractere no mapa significa. Ela contém um construtor para cada caractere, exceto para o caractere de espaço, que sempre será referido como null.

Para isso crie um novo arquivo JavaScript mundo.js, com o conteúdo da Listagem 7.

function elementoPorChar(legenda, char) {
 if(char == " ")
   return null;
 var elemento = new legenda[char]();
 elemento.charOrigem = char;
 return elemento;
}

function Mundo(mapa, legenda) {
 var grid = new Grid(mapa[0].length, mapa.length);
 this.grid = grid;
 this.legenda = legenda;

 mapa.forEach(function(linha, y) {
   for(var x = 0; x < linha.length; x ++)
         grid.set(new Vector(x, y), elementoPorChar(legenda, linha[x]));
 });
}

function charPorElemento(elemento) {
 if(elemento == null)
   return " ";
 else
   return elemento.charOrigem;
}

Mundo.prototype.toString = function() {
 var output = "";
 for(var y = 0; y < this.grid.height; y ++) {
   for(var x = 0; x < this.grid.width; x ++) {
         var elemento = this.grid.get(new Vector(x , y));
         output += charPorElemento(elemento);
   }
   output += "\ n ";
 }
 return output;
};
Listagem 7. Conteúdo inicial do objeto Mundo

Na função elementoPorChar() criamos primeiramente uma instância do tipo “legenda” verificando se o atributo char é diferente de null e aplicando o operador new nele. Em seguida, adicionamos a propriedade charOrigem a ele para tornar fácil o processo de descobrir de qual caractere o elemento foi originalmente criado.

Precisamos desta propriedade charOrigem ao implementar o método toString do objeto mundo. Este método constrói uma string do tipo mapa através do estado atual do objeto mundo, por intermédio da realização de um loop bidimensional sobre os quadrados da grid.

Observe também que a primeira verificação que fazemos na função elementoPorChar() é checar se o valor do char passado como parâmetro constitui um valor vazio, atendendo assim às exigências que definimos anteriormente.

Da mesma forma, na linha 20 temos a realização do processo inverso, que é a busca de um caractere em específico a partir de um elemento x. O uso do operador prototype (linha 27) é importante para estabelecermos um critério de herança que deverá ser obedecido por todos os objetos filhos de Mundo. Dessa forma, garantimos que, independentemente da quantidade e qualidade de objetos que forem criados em dependência hierárquica do objeto Mundo, continuaremos tendo os conceitos implementados devidamente obedecidos.

A listagem como um todo será muito importante para manter os elementos organizados dentro do objeto mundo. A base da inteligência artificial é o aprendizado contínuo, logo, quanto mais informações arquivadas pelo sistema, mais chances de sucesso o mesmo terá.

Em se tratando de um objeto Parede, temos uma associação simples: ele será usado somente para tomar espaço e não terá nenhum método agir(), diferente dos demais objetos. Veja como é simples sua representação:

function Parede() {}

Finalmente, quando testamos o objeto Mundo criando uma instância baseada no plano que definimos antes e então chamamos o método toString nele, teremos uma string muito similar ao plano que criamos como base. Veja na Listagem 8 um possível resultado de exemplo para que você possa entender como a impressão desse objeto funcionará, uma vez que será através dela (toString) que poderemos verificar o estado do nosso “mundo” sempre que quisermos.

var mundo = new Mundo(plano, 
 {"@": Parede,
  "o ": RedondezasCriatura}) ;
  
console.log(mundo.toString()) ;
 
// → @@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @           @   @ ()        @@
// @                            @
// @          @@@@     @        @
// @@      @  @      @@         @
// @@@         @@    @          @
// @        @@@ @               @
// @                 @@@      @ @
// @                 @@ ()      @
// @ ()             @ ()    @@@ @
// @                          @ @
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Listagem 8. Exemplo de impressão do conteúdo do objeto Mundo

O escopo this

O construtor do objeto Mundo contém uma chamada para um forEach. Uma coisa interessante a notar é que dentro da função passada para o foreach, não estamos mais diretamente no escopo da função do construtor. Cada chamada de função recebe a sua própria ligação para o operador this, logo o this na função interna não se refere ao objeto construído recentemente ao qual o this externo se refere. Na verdade, quando uma função não é chamada como um método, o this vai se referir ao objeto global.

Isso significa que não podemos escrever um this.grid para acessar a rede a partir de dentro do loop. Em vez disso, a função externa cria uma variável local normal, grid, através da qual a função interna tem acesso ao grid genérico.

Isto constitui um erro de design em JavaScript. Felizmente, a próxima versão da linguagem proporciona uma solução para este problema. Enquanto isso, existem soluções alternativas.

Um padrão comum é usar o código var auto = this e daí em diante referir-se sempre à variável auto, que é uma variável normal e, portanto, visível para funções internas.

Outra solução é a utilização do método bind, que nos disponibiliza um objeto this explicitamente, proporcionando a possibilidade de vincular o mesmo. Façamos um teste então. No mesmo código que criamos antes na página HTML, adicione o conteúdo da Listagem 9 e execute novamente a página.

var teste = {
 prop : 10,
 addPropTo : function(vetor) {
   return vetor.map(function(elt) {
         return this.prop + elt;
   }.bind(this));
 }
};
console.log(teste.addPropTo([4]));
Listagem 9. Testando bind/this

O resultado dessa execução será [14]. Isso se deve por que a função addPropTo mapeia o vetor que passamos como argumento e o direciona para a função interna vetor.map(), que, por sua vez, adicionará o valor 10 ao resultado final.

A função passada para o mapa é o resultado da chamada ao bind e, portanto, tem o seu this vinculado ao primeiro argumento dado para o bind – o valor da função externa ao this (que detém o objeto de teste).

A maioria dos métodos de ordem superior em matrizes padrão, tais como forEach e map, tem um segundo argumento opcional que pode também ser usado para fornecer um this para as chamadas à função de iteração. Então, pode-se expressar o exemplo anterior de uma forma um pouco mais simples, como mostra a Listagem 10.

01 var teste = {
02     prop : 10,
03     addPropTo : function (vetor) {
04           return vetor.map(function(elt) {
05                  return this.prop + elt;
06           }, this); // ← sem bind
07     }
08 };
Listagem 10. Exemplo de bind/this simplificados

É importante lembrar que essa implementação funciona apenas para as funções de ordem superior que suportam tal parâmetro de contexto. Quando não o fazem, é preciso usar uma das outras abordagens.

Em nossas próprias funções de ordem superior, podemos apoiar tal parâmetro de contexto usando o método call para chamar a função dada como um argumento. Por exemplo, veja na Listagem 11 um método forEach para o nosso tipo de grid, que chama uma função dada para cada elemento na grid que não é nulo ou indefinido.

Grid.prototype.forEach = function (f, contexto) {
   for (var y = 0; y < this.height ; y ++) {
         for (var x = 0; x < this.width ; x ++) {
                var valor = this.espaco[ x + y * this.width ];
                if (valor != null)
                       f.call(contexto, valor, new Vector(x, y));
         }
   }
};
Listagem 11. Exemplo de forEach para elementos não-nulos e indefinidos

Veja que dessa vez estamos fazendo chamadas explícitas ao método call, passando o mesmo contexto recebido (linha 6), de modo que o valor enviado à função obedece a mesma regra de iteração dupla sobre a matriz. Isso irá otimizar, em muito, o funcionamento dos nossos algoritmos.

Animando os objetos

O próximo passo é escrever o método turn() para o objeto de Mundo que dá às criaturas a chance de agir. Ele vai passar por cima da grid usando o método forEach que acabamos de definir, à procura de objetos com um método agir(). Quando encontrar um, a função turn() chama esse método para obter um objeto de ação e realizar a mesma quando for válida. Por enquanto, apenas as ações "mover" são compreendidas.

Existe um problema potencial com esta abordagem. Se deixamos as criaturas se moverem à medida que nos deparamos com elas, elas podem se mover para um quadrado que não olhamos ainda, e iremos permitir que elas se movam novamente quando chegarmos a esse quadrado. Assim, temos que manter um array de criaturas que já tiveram sua vez e ignorá-las quando as vermos novamente.

Para isso, adicione o conteúdo da Listagem 12 ao nosso arquivo mundo.js.

Mundo.prototype.turn = function() {
   var agiu = [];
   this.grid.forEach(function(criatura, vector) {
         if (criatura.agir && agiu.indexOf(criatura) == -1) {
                agiu.push(criatura);
                this.letAct(criatura, vector);
         }
   }, this);
};
Listagem 12. Criando a função turn()

Perceba que nesta listagem temos várias referências a estruturas nativas do JavaScript. A função push() usada na linha 5 é célebre dos objetos de vetor/matriz na linguagem e serve para adicionar um novo elemento. Essa estrutura se assemelha à dos métodos add() de linguagens como Java, C#, uma vez que define um escopo dinâmico de preenchimento dos vetores, exatamente o que queremos aqui. Veja que na linha 2 criamos um vetor vazio sem definir o seu tamanho limite, o que será útil pois não sabemos que tamanho é esse, já que ele não deverá ter um limite. O JavaScript permite esse tipo de implementação com arrays e faremos amplo uso dela ao longo do artigo.

Além disso, usamos o segundo parâmetro para que o método forEach da grid pudesse acessar o this correto que está dentro da função interna. O método letAct contém a lógica real que permite que as criaturas se movam. Para que tudo funcione, adicionemos também a criação dessa função ao arquivo de mundo.js, conforme demonstrado na Listagem 13.

Mundo.prototype.letAct = function(criatura, vector) {
   var acao = criatura.agir(new View(this, vector));
   if(acao && acao.type == "mover") {
         var destino = this.checarDestino(acao, vector);
         if(destino && this.grid.get(destino) == null) {
                this.grid.set(vector, null);
                this.grid.set(destino, criatura);
         }
   }
};

Mundo.prototype.checarDestino = function(acao, vector) {
   if(direcoes.hasOwnProperty(acao.direcao)) {
         var destino = vector.plus(direcoes[ acao.direcao ]);
         if(this.grid.isDentro(destino))
                return destino;
   }
};
Listagem 13. Criando a função letAct() para habilitar a ação das criaturas

Em primeiro lugar, simplesmente pedimos à criatura para agir, passando para ela um objeto de view que sabe tudo sobre o objeto mundo e sobre a posição atual dela no mesmo. O método agir() retorna uma ação de algum tipo.

Se o tipo de ação não for "mover", ela será ignorada. Caso contrário, e se ela tiver a propriedade direcao se referindo a uma direção válida, e se o quadrado daquela direção estiver vazio (null), então criamos um novo quadrado exatamente onde a criatura mantinha o seu valor como null. No final só precisamos armazenar a criatura no quadrado de destino.

Note que a função letAct cuida de ignorar entradas sem sentido (ela não assume que a propriedade de direção da ação seja válida ou que a propriedade de tipo faça sentido). Este tipo de programação defensiva faz sentido em algumas situações. A principal razão para fazê-la é quando desejamos validar entradas vindas a partir de fontes que você não controla (como usuário ou arquivos de entrada), mas pode também ser útil para isolar subsistemas uns dos outros. Neste caso, a intenção é que as criaturas possam ser programadas por elas mesmas, ou seja, elas não têm de verificar se as suas ações fazem sentido. Elas podem apenas solicitar uma ação, e o mundo vai verificar se a permite.

Estes dois métodos não fazem parte da interface externa de um objeto Mundo. Eles são um detalhe interno. Algumas linguagens fornecem maneiras para declarar explicitamente certos métodos e propriedades particulares e sinalizar um erro quando você tentar usá-los de fora do objeto. O JavaScript não faz isso, então terá que confiar em alguma outra forma de comunicação ao descrever o que faz parte da interface de um objeto. Às vezes, pode ajudar usar um esquema de nomeação para distinguir entre propriedades externas e internas, por exemplo, ao prefixar todas as propriedades internas com um sublinhado (_). Isso fará com que os usos acidentais de propriedades que não sejam parte da interface de um objeto sejam mais fáceis de detectar.

A única parte que falta, o tipo da View, está descrita na Listagem 14. Adicione o referido código em um novo arquivo chamado view.js no mesmo diretório dos demais.

function View(mundo, vector) {
    this.mundo = mundo;
    this.vector = vector;
}

View.prototype.observar = function(direcao) {
    var alvo = this.vector.plus(direcoes[direcao]);
    if(this.mundo.grid.isDentro(alvo))
          return charPorElemento(this.mundo.grid.get(alvo));
    else
          return "@";
};

View.prototype.findAll = function(char) {
    var encontrados = [];
    for(var direcao in direcoes)
          if(this.observar(direcao) == char)
                 encontrados.push(direcao);
    return encontrados;
};

View.prototype.find = function(char) {
    var encontrados = this.findAll(char);
    if(encontrados.length == 0) return null;
    return elementoRandomico(encontrados);
};
Listagem 14. Criando a função letAct() para habilitar a ação das criaturas

O método observar() descobre as coordenadas que estamos tentando observar e, se elas estiverem no interior da grid, encontra o caractere correspondente ao elemento que fica lá. Por fim, se a função não encontrar nada, ela retornará um caractere referente à simulação da borda da parede.

Perceba que todos os métodos que comentamos antes estão agora devidamente implementados, tais como find e findAll (linhas 14 e 22). Esses métodos fazem buscas simples dentro do vetor de direções. Veja que, mesmo tal vetor tendo sido criado em um outro arquivo, ambas as funções têm acesso umas às outras em detrimento das importações de arquivos que devem ser devidamente feitas na página de teste HTML. Esquecer esse tipo de importação implicará automaticamente em erros que serão impressos das mais diversas formas no console de logs do browser. Você poderá corrigi-los manualmente interpretando as mensagens de erro caso elas ocorram.

Agora que adicionamos todos os métodos necessários, será possível testar tudo, fazendo com que o mundo se mova. Para isso, a lógica de teste simplesmente precisa iterar sobre o método turn() definindo a quantidade de movimentos que tais objetos terão. Veja na Listagem 15 o código para efetuar o referido teste, bem como o seu respectivo resultado impresso por meio da chamada à função toString().

  for (var i = 0; i < 10; i++) {
      mundo.turn();
      console.log(mundo.toString());
  }
   
  // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
  // @           @   @ ()        @@  @           @   @           @@
  // @                            @  @                            @
  // @          @@@@     @        @  @          @@@@     @   ()   @
  // @@      @  @      @@         @  @@      @  @      @@         @
  // @@@         @@    @          @  @@@         @@    @          @
  // @        @@@ @               @  @        @@@ @       ()      @
  // @                 @@@      @ @  @  ()             @@@      @ @
  // @                 @@ ()      @  @                 @@         @
  // @ ()             @ ()    @@@ @  @                @       @@@ @
  // @                          @ @  @                 ()       @ @
  // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Listagem 15. Testando o exemplo e fazendo o mundo se mover

Lembre-se de que essa execução leva em consideração a seleção randômica de valores, portanto, os resultados não são previsíveis e as posições de cada criatura assumirão valores variados. Contudo, conseguimos através disso, provar que essa movimentação inteligente é possível.

Adicionando novas formas de vida

O destaque dramático do nosso mundo, se você prestar um pouco de atenção, acontece quando duas criaturas saltam umas contra as outras. Você consegue pensar em outra forma interessante de implementar esse comportamento?

Uma certeza que temos sobre o modelo é que as criaturas se movem ao longo das paredes. Conceitualmente, ela mantém a sua mão esquerda na parede e segue junto à mesma, o que não é inteiramente trivial de implementar.

Precisamos ser capazes de "calcular" as direções da bússola. Até mesmo direções são modeladas por um conjunto de strings, logo precisamos definir nossa própria operação (dirSoma) para calcular as direções relativas. Então, criaremos uma função chamada dirSoma("n", 1) que, ao ser chamada, efetuará uma volta de 45 graus no sentido horário do norte, retornando o valor "ne". Da mesma forma, ao chamar a função com os parâmetros dirSoma("s", -2) significa que giraremos a 90 graus no sentido anti-horário a partir do sul, que fica a leste. Acompanhe a Listagem 16 que apresenta a criação do objeto SeguidorParede, que será responsável por definir a forma como os objetos deverão efetuar viradas e caminhadas no nosso exemplo.

function dirSoma(dir, n) {
   var index = direcoes.indexOf(dir);
   return direcoes[(index + n + 8) % 8];
}

function SeguidorParede() {
   this.dir = "s";
}

SeguidorParede.prototype.agir = function(view) {
   var inicio = this.dir;
   if (view.observar(dirSoma(this.dir, -3)) != " ")
         inicio = this.dir = dirSoma(this.dir, -2);
   while (view.observar(this.dir) != " ") {
         this.dir = dirSoma(this.dir, 1);
         if(this.dir == inicio) break;
   }
   return { tipo: "mover", direcao: this.dir };
};
Listagem 16. Criando objeto SeguidorParede

Você pode ficar à vontade para definir onde quer inserir o script, no mesmo arquivo de view ou em um novo arquivo separado para facilitar a organização.

O método agir definido na linha 10 tem o único trabalho de "scanear" os arredores da criatura, começando a partir do seu lado esquerdo e indo no sentido horário até que ele encontre um quadrado vazio; quando encontrar, ele se moverá na direção do mesmo.

O que complica nessa situação é que uma criatura pode acabar no meio de um espaço vazio, quer como a sua posição de partida, quer como um resultado de caminhar em torno de outra criatura. Se aplicarmos a abordagem que acabamos de descrever no espaço vazio, a criatura vai apenas continuar a virar à esquerda a cada passo, correndo em círculos infinitamente.

Portanto, há uma verificação extra (a instrução if, na linha 12) para iniciar a scanneamento para a esquerda apenas se parecer que a criatura acabou de passar por algum tipo de obstáculo, isto é, se o espaço atrás e à esquerda da criatura não estiver vazio. Caso contrário, ela começará a scannear diretamente à frente, de modo que andará em linha reta quando estiver em um espaço vazio.

Finalmente, há um teste comparando as variáveis this.dir a inicio após cada passagem através do laço para se certificar de que o loop não será executado para sempre quando a criatura estiver cercada por paredes ou por outras criaturas e não puder encontrar um quadrado vazio.

Uma simulação mais realista

Para tornar a vida em nosso mundo mais interessante, vamos adicionar os conceitos de alimentação e reprodução. Cada coisa viva no mundo recebe uma nova propriedade, energia, a qual é reduzida ao realizar ações e aumentada ao comer coisas. Quando a criatura tem energia suficiente, ela pode reproduzir, gerando uma nova criatura do mesmo tipo. Para manter as coisas simples, as criaturas em nosso mundo se reproduzem assexuadamente, por si mesmas.

Se as criaturas só se movem ao redor e comem umas às outras, o mundo em breve sucumbirá à lei da entropia crescente, ficará sem energia, e tornar-se-á um deserto sem vida. Para evitar que isso aconteça, adicionaremos plantas ao mundo.

Para fazer este trabalho, vamos precisar de um mundo com um método letAct diferente. Poderíamos simplesmente substituir o método do protótipo de Mundo, mas como implementamos a simulação inicialmente com as criaturas seguindo as paredes, vamos trabalhar tentando manter o modelo atual.

Uma solução é usar a herança. Criamos um novo construtor, VidaComoMundo, cujo protótipo é baseado no protótipo mundo, mas que substitui o método letAct. O novo método letAct delega o trabalho de realmente executar uma ação para várias funções armazenadas no objeto tipoAcoes.

Para isso, crie um novo arquivo chamado vidacomomundo.js e adicione o conteúdo da Listagem 17 ao mesmo.

function VidaComoMundo(mapa, legenda) {
   Mundo.call(this, mapa, legenda);
}

VidaComoMundo.prototype = Object.create(Mundo.prototype);
var tipoAcoes = Object.create(null);

VidaComoMundo.prototype.letAct = function(criatura, vector) {
   var acao = criatura.agir(new View(this, vector));
   var manipulados = acao &&
         acao.tipo in tipoAcoes &&
         tipoAcoes[ acao.tipo ].call(this, criatura, vector, acao);
   
   if (!manipulados) {
         criatura.energia -= 0.2;
         if (criatura.energia <= 0)
                this.grid.set(vector, null);
   }
};
Listagem 17. Criando o objeto VidaComoMundo

O novo método letAct verifica primeiro se uma ação foi devolvida de uma forma geral, em seguida, se uma função de manipulação para este tipo de ação existe, e finalmente se esse manipulador retornou true, indicando que ele lidou com a ação com sucesso. Observe o uso da função call para dar ao manipulador o acesso ao mundo, por meio de sua ligação com o operador this.

Se a ação não funcionar por qualquer motivo, a ação padrão é a criatura simplesmente esperar. Ela perde um quinto de energia, e se seu nível de energia cai para zero ou abaixo, a criatura morre e é removida da grid.

Action Handlers

A ação mais simples que uma criatura pode executar é "crescer", usada pelas plantas. Quando um objeto de ação como {tipo: "crescer"} é retornado, o manipulador de métodos representado na Listagem 18 será chamado.

tipoAcoes.crescer = function(criatura) {
   criatura.energia += 0.5;
   return true;
};
Listagem 18. Exemplo de manipulador de método

A ação de crescer sempre funciona e adiciona meio ponto ao nível de energia da criatura. Já para movimentar o objeto temos um código mais complexo, como o da Listagem 19.

tipoAcoes.mover = function (criatura, vector, acao) {
   var dest = this.checarDestino(acao, vector);
   if (dest == null ||
                criatura.energia <= 1 ||
                this.grid.get(dest) != null)
         return false;
   criatura.energia -= 1;
   this.grid.set(vector, null);
   this.grid.set(dest, criatura);
   return true;
};
Listagem 19. Criando o método mover para tipoAcoes

Esta ação verifica primeiro, usando o método checarDestino() definido anteriormente, se a ação fornece um destino válido. Se não, ou se o destino não estiver vazio, ou se a criatura não tiver a energia necessária, a função mover() retorna false para indicar que nenhuma ação foi tomada. Caso contrário, desloca-se a criatura e subtrai-se o custo de energia.

Além de tudo isso, as criaturas também podem comer. Veja na Listagem 20 o código necessário para implementar essa funcionalidade.

tipoAcoes.comer = function(criatura, vector, acao) {
   var dest = this.checarDestino(acao, vector);
   var atDest = dest != null && this.grid.get(dest);
   if (!atDest || atDest.energia == null)
         return false;
   criatura.energia += atDest.energia;
   this.grid.set(dest, null);
   return true;
};
Listagem 20. Implementando ação de comer

Comer outra criatura também envolve o fornecimento de um quadrado de destino válido. Desta vez, o destino não pode estar vazio e deve conter algo com energia, como uma criatura (mas não uma parede, paredes não são comestíveis). Se isso acontecer, a energia da criatura consumida é transferida para quem a comeu, e a vítima é removida da grid.

O mais interessante é que o leitor note que esse tipo de estrutura é flexível e pode ser livremente modificada. O mundo se moverá e tomará rumos próprios baseados na inteligência que você implementou, logo o imprevisível é a melhor resposta para o que pode acontecer.