Com um design minimalista, dois personagens devem se enfrentar em um campo de batalha, que de tempos em tempos disponibiliza efeitos que devem ser coletados (ou não) para garantir a vitória.
Visitar demostraçãoUm jogo desenvolvido em JavaScript, com o front-end em Vue.js e Vuetify, em que dois personagens devem competir para vencer a batalha.
Dois personagens com a mesma quantidade de vida são postos nas extremidades do campo, o jogador deve utilizar as teclas direcionais para iniciar o movimento de seu personagem. Mas atenção! Vá com calma. Os personagens não devem se atacar inicialmente, pois perderão a mesma quantidade de vida quando colidirem entre si, o que resultaria em um empate após o esgotamento das 3 vidas que acompanham cada um.
Para que haja um vencedor (e por consequência um perdedor), estes devem coletar efeitos que irão aparecer em tempos e posições aleatórias dentro do campo. os efeitos podem ser positivos para o personagem ou não. Dentre eles há algumas subcategorias, cada um com sua peculiaridade, adicionados para dar dinâmica às partidas.
Foram criados dois modos de jogo:
No modo jogador versus a máquina, utilizei um algoritmo genético com neuro-evolução para treinar uma população de agentes reativos. O treino teve como finalidade selecionar uma geração de genomas, utilizados como cérebro do agente reativo e responsáveis por definir quando e como o personagem deve agir, considerando fugir do oponente se em desvantagem, coletar bons efeitos e prezar por vitórias.
A biblioteca utilizada para o treino foi a Neataptic. Esta exporta uma classe NEAT, que contém todo um aparato de funções para, dadas entradas e um determinado escore, otimizar as saídas da rede neural baseado numa função de aptidão.
Não é o objetivo deste texto explicar conceitos de algoritmos evolutivos, portanto irei reservar-me a apresentar pequenos detalhes sobre o seu funcionamento e descrever como foi feita sua implementação no jogo.
├── assets/ # ativos do projeto
├── components/ # UI componentes
├── game/ # os arquivos de lógica do jogo (mais detalhado adiante)
├── plugins/ # extenções do Vue, como Vuetify e EventBus
├── router/ # arquivos de configuração de rotas
├── store/ # store Vuex para centralização de dados
├── views/ # componentes renderizados em cada rota
├── main.js # arquivo de entrada do Webpack
├── App.vue # componente/template principal
game/ # os arquivos de lógica do jogo
├── defaults/ # valores padrões utilizados nas classes do jogo
├── population/ # arquivos contendo populações geradas pelo treinamento da rede neural
├── scripts/ # classes e funções principais
│ └── Battle/ # classes de batalha, responsáveis por gerenciar os elementos do jogo e atualizar a tela
│ └── Canvas/ # classes de elementos desenhaveís e do próprio campo de batalha
│ └── Characters/ # classes de personagens do jogo, cada um representa uma cor
│ └── Effects/ # classes abstratas de efeitos e seus subtipos
│ └── engines/ # métodos disparados por eventos do teclado para controlar os personagens
│ └── functions/ # métodos úteis de cálculo e importações
│ └── objects/ # objetos úteis
│ └── Players/ # classes dos jogadores humano e agente reativo
└── vendor/ # códigos de terceiros
└── neataptic.js # o algoritmo genetico utilizado para treinar a rede neural e ativar o genoma do agente
Efeitos são elementos que surgem no campo de batalha em posições aleatórias e devem ser coletados para garantir a vitória ou a derrota. São eles que agregam dinâmica ao jogo, pois dão vantagens ou desvantagem perante ao oponente. Eles foram categorizados em algumas classes e possuem ciclo de vida.
O ciclo de vida serve para iniciar e cancelar modificações feitas pelo efeito sobre o personagem alvo. São disparados na colisão direta entre efeito versus personagem ou indireta entre personagem hospedeiro versus oponente. Ambas situações executarão a chamada do método apply.
As fases do ciclo são:
Abaixo o método apply, com os métodos que compõem o ciclo de vida:
async apply(character) {
this.$start(character)
this.$mod()
await this.$run()
this.$destroy()
}
$start(character) {
// can't be overrided
this.setCharacterOwner(character)
this.destroyPreviousEffect(character)
character.setCurrentEffect(this)
}
$mod() {
if (!this.performed_table.$mod) {
this.beforeMod()
this.mod()
this.performed_table.$mod = true
}
}
async $run() {
if (!this.performed_table.$run) {
await this.run()
this.performed_table.$run = true
}
}
$destroy() {
if (!this.performed_table.$destroy) {
this.beforeDestroy()
this.destroy()
this.destroyed()
this.performed_table.$destroy = true
}
}
A classe do tipo Battle é uma abstração de uma batalha, é responsável por reunir jogadores, gerar efeitos, esperar ações dos jogadores, emitir atualizações para a interface, desenhar no canvas, verificar colisões e montar os resultados.
run() {
const battle = this
return new Promise((resolve, reject) => {
try {
const animate = () => {
battle.canvas.clearBattleCanvas()
if (battle.battleEnded()) {
resolve(battle.players)
return;
} else {
requestAnimationFrame(animate)
VueEventBus.$emit("battleRunning", battle)
battle.updateGameElements()
battle.listenForPlayerControls()
battle.collideElements()
battle.drawBattle()
}
}
animate()
} catch (error) {
reject(error)
}
})
}
Focarei aqui na classe de batalha de um jogador, que acontece com um jogador humano contra a máquina (agente reativo). Essa possui dois métodos extras, um responsável por atualizar o ambiente para o jogador da máquina e o outro por requisitar aos jogadores que realizem uma ação.
class BattleOfflineSinglePlayer extends Battle {
constructor(players, canvas) {
super(players, canvas)
this.updateEnvironmentForAgentPlayers(this.players)
}
updateEnvironmentForAgentPlayers(players) {
for (let index in players) {
const player = players[index]
if (player instanceof AgentPlayer) player.setEnvironment(this)
}
}
listenForPlayerControls() {
this.players.forEach(player => player.requireAction())
}
}
A máquina aqui é representada pela classe AgentPlayer e é uma especificação da classe Player. É a classe que vai receber o comportamento de uma inteligência artificial do jogo. Criado como um agente reativo, possui percepção completa do ambiente (dados da batalha e do oponente).
A cada atualização da batalha será requisitado ao agente que tome uma decisão baseado no estado atual do ambiente. Esta decisão será processada com base nas suas percepções, que são métodos que retornam boleanos, indicando verdadeiro ou falso para as seguintes questões:
Cada boleano retornado pelos métodos é então normalizado em 0 (false) e 1 (true). Esse array de binários é utilizado como entrada para ativação do genoma/cérebro do agente. A saída é um array contendo 4 números indicando, respectivamente, o quão vantajoso é perseguir o efeito, perseguir o oponente, fugir do oponente ou ficar quieto. Sabendo qual ação terá melhor benefício, dado a sua percepção atual, o agente escolhe então a com maior magnitude.
Estes comportamentos foram codificados na forma dos métodos perceive, decide e act, como mostrado abaixo:
class AgentPlayer extends Player {
constructor(character, genome, nickname = "agent_player") {
super(character, nickname)
this.opponent = null
this.environment = null
this.brain = genome
this.mapAgentModules(agent_modules)
}
requireAction() {
this.update()
}
setEnvironment(environment) {
this.opponent = environment.players.find(player => player !== this)
this.environment = environment
}
perceive() {
const boolToInt = bool => {
return bool ? 1 : 0;
}
const booleans = [
this.isAgentEffectGoodToCollect(),
this.isAgentEffectGoodToAttack(),
this.isEnvironmentEffectGoodToCollect(),
this.isEnvironmentEffectGoodToAttack(),
this.isOpponentEffectGoodToAttack(),
this.isOpponentNear(),
this.isOpponentNearEffect(),
this.isEffectNear(),
this.isAgentAtAdvantage(),
this.isAgentAtAdvantageByEffects(),
this.isAgentAtAdvantageByLifes(),
]
const booleans_to_int = booleans.map(bool => boolToInt(bool))
const input = [...booleans_to_int]
return input
}
decide(perception) {
const higherIndex = output => {
let higher_index = 0
for (let index in output) {
const current = output[index]
const higher = output[higher_index]
higher_index = current > higher ? index : higher_index
}
return higher_index
}
const output = this.brain.activate(perception)
return Number.parseInt(higherIndex(output))
}
act(decision) {
switch (decision) {
case 0:
this.chaseTheEffect()
break;
case 1:
this.chaseTheOpponent()
break;
case 2:
this.runOfTheOpponent()
break;
}
}
update() {
const perception = this.perceive()
const decision = this.decide(perception)
this.act(decision)
}
}
A classe AgentPlayer requisita um genoma para tomar decisões dada uma percepção. Este faz parte de uma população de N indivíduos previamente treinados com o auxílio da biblioteca Neataptic. Essa expõe uma classe NEAT que contém todo um aparato de funções para, dadas entradas e um determinado escore, otimizar as saídas da rede neural baseado numa função de aptidão.
Para treinar os genomas foram criadas novas classes adaptadas do agente (TrainerAgentPlayer), do personagem (TrainerCharacter) e da batalha (TrainerBattle), além de uma extra (Trainer), onde a instância desta é responsável por: inicializar batalhas, observar as melhores pontuações, definir recompensas e penalidades, além de realizar a seleção e mutação de novas gerações.
class Trainer {
constructor() {
this.info = {
high_score: 0,
chase_effect: 0,
chase_opponent: 0,
run_away: 0,
win: 0,
lose: 0,
tied: 0
}
this.neat = new Neat(
INPUT_NODES,
OUTPUT_NODES,
null, {
...
popsize: POPULATION_AMOUNT,
mutationRate: MUTATION_RATE,
elitism: Math.round(ELITISM_PERCENT * POPULATION_AMOUNT),
network: new Architect.Random(
INPUT_NODES,
START_HIDDEN_SIZE,
OUTPUT_NODES
)
}
);
if (USE_TRAINED_POP) this.neat.population = loadPopulation(POPULATION_AMOUNT);
this.listenForKeydownToSaveNeatProgress()
this.iterateEvaluations()
}
...
setHighScore(score) {
this.info.high_score = score > this.info.high_score ? score : this.info.high_score;
}
startHighScore(genomes) {
const trainer = this
genomes.forEach(genome => {
if (genome.score) trainer.setHighScore(genome.score)
})
}
updateInfoWithBattlesResults(battles_results) {
const trainer = this
battles_results.forEach(pair => {
pair.forEach(agent_player => {
switch (agent_player.result) {
case "Lose":
agent_player.brain.score -= 2000
trainer.info.lose += 1
break;
case "Tied":
agent_player.brain.score -= 1000
trainer.info.tied += 1
break;
case "Win":
agent_player.brain.score *= agent_player.lifeScoreNormalized()
trainer.info.win += 1
break;
}
trainer.setHighScore(agent_player.brain.score)
})
})
}
/** End the evaluation of the current generation */
endEvaluation() {
this.neat.sort();
const new_population = [];
// Elitism
for (let i = 0; i < this.neat.elitism; i++) {
new_population.push(this.neat.population[i]);
}
// Breed the next individuals
for (let i = 0; i < this.neat.popsize - this.neat.elitism; i++) {
new_population.push(this.neat.getOffspring());
}
// Replace the old population with the new population
this.neat.population = new_population;
this.neat.mutate();
this.neat.generation++;
}
}
Durante a fase de treinamento, as redes neurais precisam ter os pesos entre as conexões entre neurônios ajustados, buscando melhorar a eficiência das saídas esperadas dada uma entrada. Nos algoritmos NEAT esses ajustes são feitos gradualmente, pois utilizam o conceito de evolução por aumento de topologias.
O principal requisito deste algoritmo evolutivo, para realização dos ajustes, é o fornecimento de um escore, que define o quão habilitado um genoma é comparado com outros indivíduos da mesma espécie. Este também servirá para filtrar quais indivíduos devem passar para a próxima geração, por meio de uma técnica de elitismo, descartando os menos aptos.
Para esta implementação, o escore de cada agente foi definido pelo acúmulo: dos valores atribuídos a cada tipo de efeito coletado (que podem ser negativos ou positivos); das ações executadas dada uma percepção do ambiente (recompensado ou penalizado); e do resultado final contra o oponente (empatou, venceu ou perdeu).
Exemplos de acúmulo de escore:
Para definir as recompensas e penalidades, por executar ações, foi criada uma tabela de decisão.
A distribuição de recompensas e penalidades ficou definido pela criação de uma tabela de decisão.
Dado a complexidade 2n para se implementar uma tabela que atenda aos 11 boleanos citados anteriormente, optou-se por gerar uma versão com menos variáveis.
O impacto disso poderia ser um agente treinado com menos percepção do ambiente e por consequência menos inteligente.
Esta abstração fez-se necessária para não tomar muito tempo do desenvolvimento do jogo, já que a meta não era criar um jogador capaz de superar o humano, mas sim adicionar certa competitividade com reações racionais do agente inteligente.
class TrainerAgentPlayer extends AgentPlayer {
constructor(character, genome, trainer) {
super(character, genome)
this.trainer = trainer
this.brain.score = 0
}
act(decision) {
let acc = 0
if(decision === 0){
// chaseTheEffect
this.trainer.info.chase_effect += 1
if (this.isEnvironmentEffectGoodToCollect()) {
acc += 1
}
if (this.isAgentEffectGoodToAttack()) {
acc -= 1
}
} else if (decision === 1){
// chaseTheOpponent
this.trainer.info.chase_opponent += 1
if (this.isAgentAtAdvantage()) {
acc += 1
}
if (this.isOpponentEffectGoodToAttack()) {
acc -= 1
}
} else if (decision === 2) {
// runOfTheOpponent
this.trainer.info.run_away += 1
if (this.isAgentAtAdvantage()) {
acc -= 1
}
if (this.isOpponentNear()) {
acc += 1
}
} else {
// doNothing
this.trainer.info.do_nothing += 1
}
const effect = this.character.effect
if (effect) {
acc += effect.power
}
this.brain.score += acc
this.trainer.setHighScore(this.brain.score)
super.act(decision)
}
}
Os indivíduos de cada geração são distribuídos em pares, assim para uma população de 100 genomas, 50 batalhas devem ser criadas. Durante a execução dos treinamentos alguns agentes podem terminar a batalha muito rápido (capturando o efeito Death, por exemplo) ou ficarem indefinidamente jogando se não realizarem ataques e coletarem efeitos, já que o jogo não possui algum tipo de tempo limite. Para isso a classe TrainerBattle utiliza uma propriedade extra chamada fitness, utilizada justamente para controlar o limite de iterações que os agentes terão para tentar alcançar o objetivo de conseguirem o melhor escore. Esta variável se inicia com um valor inteiro que é decremento a cada iteração, enquanto avalia-se o esgotamento da mesma. Determinado que o fitness está liquidado, então ambos jogadores devem ser considerados perdedores. O impacto disso é que irão receber um escore negativo e por consequência suas changes de reprodução minimizadas.
Foram realizados treinos com um total de 1000 gerações e os resultados apontaram que, inicialmente, cada geração conseguia superar o maior escore das gerações passadas. A partir de certo ponto o aprendizado se manteve estável, sem grandes melhorias no higher score. A última geração foi então persistida para ser utilizada durante o jogo.
Algumas funcionalidades novas e correções que podem/devem ser implementadas:
O objetivo não era criar um jogo que pudesse ser publicado para várias pessoas se divertirem, mas sim praticar todas as tecnológias web citadas neste artigo. Portanto a continuação do desenvolvimento e correção de erros ficarão à cargo do meu tempo livre disponível.
O código completo encontrasse no meu perfil do GitHub ou pode ser diretamente acessado por aqui.