sexta-feira, 10 de fevereiro de 2012

Tetris e algoritmos genéticos em Java - parte 2

No post anterior implementamos o cliente Tetris e testamos sua funcionalidade. Na segunda parte da série, daremos início a parte realmente interessante: AI. Para começar, a classe TetrisAgent será discutida em detalhes, já que os agentes serão a população alvo do algoritmo. Além disso iremos implementar duas classes que definem habilidades importantes: listar os lances possíveis e avaliar as posições resultantes.

Classe TetrisAgent

Essa é a classe base de nossa AI, e representa um jogador de Tetris. Três variáveis de instância são definidas: genes, clearedRowsArray e fitness. A habilidade de cada agente é expressa através de seus genes, onde cada slot da array genes corresponde ao peso atribuído a determinada característica da grade. Sabemos por experiência que determinados padrões da grade resultam em maiores chances de efetuar pontos, ou menores chances de game over.

No nosso caso, a array genes corresponde ao peso de 7 características distintas: 3 delas são consideradas básicas e 4 são menos óbvias. Dentre as básicas temos: número de linhas feitas, altura máxima da pilha e número de buracos não conectados. As outras são: número de buracos conectados, número de blocos acima de buracos, número de poços e nivelação da grade. Para efeito de testes, setamos genes com valores arbitrários entre 0 e 1, mas fique a vontade para alterar esses valores e observar o efeito no desempenho do agente. Como comentado acima, os valores finais serão determinados pela GA.

Nossa tarefa consiste em determinar o melhor agente possível, ou seja, quais são os valores de genes que resultam no maior número médio de linhas removidas? Durante a execução da GA, cada agente jogará uma sequência de n games aleatórios, onde a sequência de peças em cada um dos n games será fixada semeando-se o gerador numérico. Em outras palavras, todos os agentes enfrentarão os mesmo jogos, o que nos permite balancear o efeito de um agente ruim receber uma sequência boa de peças, ou um agente bom receber uma sequência ruim de peças. A variável fitness quantifica as definições acima, e será calculada como a média de linhas removidas durante os n games.

Finalmente, temos a variável clearedRowsArray, que irá armazenar o número de linhas removidas em cada jogo. Repare que estamos utilizando uma ArrayList sincronizada, pois como comentado no post anterior, cada game será executado em uma Thread separada, e também sabemos que o mesmo agente será compartilhado por n Threads. O problema em utilizar uma ArrayList padrão surge ao invocar agent.clearedRowsArray.add(linhas): caso duas Threads se esbarrem nesse método, uma ConcurrentModificationException seria lançada, terminando o programa.

Os métodos definidos são simples: eval recebe uma array com a configuração da grade, e utiliza os métodos estáticos da classe BoardEvaluator para avaliar a posição. A interface Comparable é implementada, nos permitindo ordenar agentes facilmente, o que será útil mais adiante. O método estático randomAgent simplesmente cria um novo agente e seta seus genes com valores randômicos entre 0 e 1. Por fim, cloneAgent retorna um novo objeto com os mesmos genes do agente que invoca o método. Segue a classe TetrisAgent:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;

public class TetrisAgent implements Comparable
{
 float[] genes = new float[]{ 0.6f, 0.3f, 0.05f, 0.1f, 0.1f, 0.1f, 0.2f};
 
 List<Integer> clearedRowsArray = Collections.synchronizedList(new ArrayList<Integer>());
 float fitness;
 
 public float eval(int[][]board)
 {
  float sum = 0.0f;
  
  sum += genes[0] * BoardEvaluator.clearedRows(board);
  sum -= genes[1] * BoardEvaluator.pileHeight(board);
  sum -= genes[2] * BoardEvaluator.countSingleHoles(board);
  sum -= genes[3] * BoardEvaluator.countConnectedHoles(board);
  sum -= genes[4] * BoardEvaluator.blocksAboveHoles(board);
  sum -= genes[5] * BoardEvaluator.countWells(board);
  sum -= genes[6] * BoardEvaluator.bumpiness(board);
  
  return sum;
 }
 
 public int compareTo(Object obj)
 {
  if (obj instanceof TetrisAgent) 
  {
   TetrisAgent other = (TetrisAgent) obj;
   if (this.fitness > other.fitness)
    return 1;
   else if (this.fitness < other.fitness)
    return -1;
  }
  return 0;
 }
 public static TetrisAgent randomAgent()
 {
  TetrisAgent agent = new TetrisAgent();
  Random random = new Random();
  
  for (int i = 0; i < agent.genes.length; i++)
   agent.genes[i] = random.nextFloat();
  
  return agent;
 }
 
 public TetrisAgent cloneAgent()
 {
  TetrisAgent cloneAgent = new TetrisAgent();
  
  for (int i = 0; i < this.genes.length; i++)
   cloneAgent.genes[i] = this.genes[i];
  
  return cloneAgent;
 }
}

Classe BoardEvaluator

Esta classe define métodos estáticos, utilizados pelo agente para avaliar determinada configuração da grade. Os métodos clearedRows, pileHeight e countSingleHoles retornam o número de linhas removidas, a altura máxima, e o número de buracos, respectivamente.

O método countConnectedHoles retorna o número de buracos conectados, ou seja, buracos adjacentes são contados apenas uma vez. O método blocksAboveHoles retorna a quantidade de blocos que se encontram acima de buracos.

O método countWells retorna o número de poços, ou seja, regiões onde somente a peça do tipo I pode ser encaixada sem deixar buracos. Por fim, bumpiness calcula a nivelação da grade, observando as diferenças de altura entre colunas adjacentes. Com exceção de clearedRows, admitimos que todas as características avaliadas contribuem para o deterioramento da posição, por isso o sinal negativo. Segue a classe BoardEvaluator:

public class BoardEvaluator
{ 
 public static int clearedRows(int[][] board)
 {
  int cleared = 0;
  int columns = board[0].length;
  
  for(int y = 0; y < board.length - 1; y++) 
  {
   boolean ok = true;
   for(int x = 1; (x < columns - 1) && ok; x++) 
    if(board[y][x] == 0) 
     ok = false;
     
   if(ok) cleared++;
  }
  return cleared;
 }
 
 public static int pileHeight(int[][] board)
 {
  int columns = board[0].length;
  
  for(int y = 0; y < board.length - 1; y++) 
  {
   for(int x = 1; x < columns - 1; x++) 
   {
    if(board[y][x] != 0) 
     return board.length - 1 - y;
   }
  }
  return 0;
 }
 
 public static int countSingleHoles(int[][] board)
 {
  int holes = 0;
  int columns = board[0].length;
  
  for (int j = 1; j < columns - 1; j++)
  {
   boolean swap = false;
   for (int i = 0; i < board.length - 1; i++)
   {
    if (board[i][j] != 0)
     swap = true;
    else 
    {
     if (swap)
      holes++;
    }
   }
  }  
  return holes;
 }
 
 public static int countConnectedHoles(int[][] board)
 {
  int holes = 0;
  int columns = board[0].length;
  
  for (int j = 1; j < columns - 1; j++)
  {
   boolean swap = false;
   for (int i = 0; i < board.length - 1; i++)
   {
    if (board[i][j] != 0)
     swap = true;
    else 
    {
     if (swap)
      holes++;
     swap = false;
    }
   }
  }
  
  return holes;
 }
 
 public static int blocksAboveHoles(int[][] board)
 {
  int blocks = 0;
  int cols = board[0].length;
  
  for(int c = 1; c < cols - 1; c++)
  {
   boolean swap = false;
   for (int r = board.length - 2; r >= 0; r--)
   {
    if (board[r][c] == 0)
     swap = true;
    else 
    {
     if (swap)
      blocks++;
    }     
   }   
  }   
  return blocks;
 }
 
 public static int countWells(int[][] board)
 {
  int cols = board[0].length;
  int wells = 0;
  
  //da segunda até a  penultima coluna
  for (int col = 2; col < cols - 2; col++)
  {
   for (int row = 0; row < board.length - 1; row++)
   {
    if ((board[row][col - 1] > 0) && (board[row][col + 1] > 0) && (board[row][col] == 0))
     wells++;
    else if (board[row][col] > 0)
     break;
   }
  }
  
  //primeira coluna
  for (int row = 0; row < board.length - 1; row++)
  {
   if ((board[row][1] == 0) && (board[row][2] > 0))
    wells++;
   else if (board[row][1] > 0)
    break;
  }
  //ultima coluna
  for (int row = 0; row < board.length - 1; row++)
  {
   if ((board[row][cols - 3] > 0) && (board[row][cols - 2] == 0))
    wells++;
   else if (board[row][cols - 2] > 0)
    break;
  }  
  return wells;
 }
 
 public static float bumpiness(int[][] board)
 {
  int cols =  board[0].length;
  int[] heights = new int[cols - 2];
  
  for(int x = 1; x < cols - 1; x++) 
  {
   for(int y = 0; y < board.length - 1; y++) 
   {
    if(board[y][x] != 0) 
    {
     heights[x - 1]  = board.length - 1 - y;
     break;
    }
   }
  }
  
  float bmp = 0.0f;
  for (int i = 0; i < cols - 3; i++)
  {
   float diff = Math.abs(heights[i] - heights[i + 1]);
   bmp += diff;
  }
  
  return bmp;
 }
}  //fim da classe BoardEvaluator

Classe TetrisMove

Objetos dessa classe representam um lance que pode ser feito, dado determinada peça e board. Os campos position e rotation representam a posição em que a peça será encaixada e a rotação, respectivamente. O campo eval representa a qualidade deste lance, de acordo com a avaliação do agente. Segue a classe TetrisMove:

public class TetrisMove
{
 Point position = new Point(0, 0);
 int rotation;
 float eval;
 
 public TetrisMove(){}
 public TetrisMove(Point p, int rot )
 {
  this.position = p;
  this.rotation = rot;
 }
}

Classe MoveSearcher

Outra classe com métodos estáticos, mas desta vez a idéia é listar todos os lances possíveis, retornando o melhor. O código de getValidMoves parece complicado, mas é bem simples: (i) movemos a peça o máximo possível para a esquerda e (ii) fazemos um drop, adicionando o lance resultante na lista. (iii) Voltamos a peça verticalmente para o topo e movemos para a direita. Os processos (ii) e (iii) são repetidos até ser impossível mover para direita, quando passamos para a próxima rotação e repetimos a partir de (i).

Repare que o agente é passado como argumento, e o método eval do objeto TetrisAgent é invocado. Outro detalhe importante é o método removePiece da classe TetrisBoard, queé utilizado para desfazer o lance, logo após efetuar o drop. O método getBestMove simplesmente invoca getValidMoves e retorna o lance com maior eval. Segue a classe MoveSearcher:

import java.util.ArrayList;

public class MoveSearcher
{

 public static ArrayList<TetrisMove> getValidMoves (TetrisBoard board, TetrisPiece piece, TetrisAgent agent)
 {
  ArrayList<TetrisMove> moves = new ArrayList<TetrisMove>();  
  Point originalPosition = new Point(piece.position.x, piece.position.y);
  
  for (int i = 0; i < 4; i++)
  {
   piece.rotation = i;   
   int startY = piece.position.y;
   
   while (board.canMoveLeft(piece))
    piece.moveLeft();
   
   do 
   {
    while(board.canMoveDown(piece))
     piece.moveDown();
    
    board.updateBoard(piece);
      
    float eval = agent.eval(board.board);
    TetrisMove tempMove = new TetrisMove();
    tempMove.eval = eval;
    tempMove.position.x = piece.position.x;
    tempMove.position.y = piece.position.y;
    tempMove.rotation = piece.rotation; 
    
    moves.add(tempMove);
    
    board.removePiece(piece);
    piece.position.y = startY;
    
    if (! board.canMoveRight(piece))
    {
     break;
    }
    else
    {
     piece.moveRight();
    }
   
   } while(true);

   piece.position = originalPosition;
  }

  return moves;
 }
 
 public static TetrisMove getBestMove (TetrisBoard board, TetrisPiece piece, TetrisAgent agent)
 {
  TetrisMove best = null;
  ArrayList<TetrisMove> moves = getValidMoves(board, piece, agent);
  
  for(TetrisMove move : moves)
  {
   if (best == null)
    best = move;
   else if (move.eval > best.eval)
    best = move;
  }
  return best;
 }
 
}  //fim da classe MoveSearcher

Classe AIGame

Essa classe define o game loop da AI, nos permitindo visualizar o agente jogando. Como comentado anteriormente, durante a execução da GA, cada agente será compartilhado por n Threads diferentes, onde cada Thread representa um game com uma sequência fixa de peças. Veja a figura abaixo:

No post anterior, implementamos a classe GamePanel, que permite a execução de um game em uma Thread separada, aceitando input do teclado. Nest post, AIGame irá herdar de GamePanel e sobrescrever o método run.

Olhando um pouco adiante, sabemos que a qualidade de cada agente será quantificada tomando-se a média do número de linhas removidas em n games. Ou seja, para calcular esse valor, devemos nos certificar de que todas as Threads deste agente tenham finalizado a execução. A classe CountDownLatch, do pacote java.util.concurrent, nos permite fazer essa sincronia facilmente. Como estamos testando apenas um agente, não há necessidade de sincronia, e a variável doneSignal será null por enquanto. A variável cleared representa o número de linhas removidas ao fim do jogo.

O override do método run é simples: Consultamos MoveSearcher para obter o melhor lance, passando como argumentos a grade, uma cópia da peça e o agente. Atualizamos o estado do game de maneira similar ao método da superclasse, mas aqui incrementamos a variável cleared a cada linha removida. Fazemos a Thread dormir por um pequeno intervalo de tempo, o que permite uma renderização mais suave.

Em caso de game over, atualizamos a variável clearedRows do agente e invocamos doneSignal.countDown, sinalizando que esta Thread finalizou seu trabalho. Esse mecanismo ficará claro no próximo post, mas por enquanto não faz diferença, afinal doneSignal é null por enquanto. Segue a classe AIGame:

import java.util.Random;
import javax.swing.*;
import java.awt.*;
import java.util.concurrent.*;

import java.io.*;

public class AIGame extends GamePanel
{
 private CountDownLatch doneSignal;
 private boolean doSleep = false;
 TetrisAgent agent = new TetrisAgent();
 int cleared = 0;
 
 public AIGame(int r, int c, int bs, int seed, CountDownLatch signal)
 {
  this(r, c, bs, seed, false);
  this.doneSignal = signal;
 }
 public AIGame(int r, int c, int bs, int seed, boolean doSleep)
 {
  super(r, c, bs, seed);
  this.doSleep = doSleep;
 }
 
 public AIGame(int r, int c, int bs, boolean doSleep)
 {
  super(r, c, bs);
  this.doSleep = doSleep;
 }
     
 public void run()
 {
  fallingPiece = randomPiece();
  
  while (!gameOver)
  {
   if (board.canMoveDown(fallingPiece))
   {
    TetrisMove bestMove = MoveSearcher.getBestMove(board, fallingPiece.clonePiece(), agent);
    
    fallingPiece.position = bestMove.position;
    fallingPiece.rotation = bestMove.rotation;
    
    board.updateBoard(fallingPiece);
    
   }
   else 
   {
    board.updateBoard(fallingPiece);
    cleared +=board.checkCompleteRows();
    addNewPiece();  
   }
   
   if (doSleep)
   {
    try 
    {
     Thread.sleep(20L);
    }
    catch(InterruptedException ex){}
   }
   
   repaint();
  }
  
  agent.clearedRowsArray.add(cleared);  //acessado por varias threads!
  if (doneSignal != null)
   doneSignal.countDown();
   
 }
 
}  //fim da classe AIGame

Testando o agente

Chegou a hora de testar a estrutura da AI, colocando um agente para jogar. Lembrando que o agente teve seus genes hard-coded na classe TetrisAgent, você pode alterar esses valores manualmente ou simplesmente usar ai.agent = TetrisAgent.randomAgent(), e testar um agente randômico. Segue a classe TestAgent:

import javax.swing.*;
import java.awt.*;

public class TestAgent
{
 public static void main (String[] args)
 {
  JFrame f = new JFrame();
  int rows = 20, cols = 10, block = 32;
  boolean sleep = true;  //para atualizar o panel
  AIGame ai = new AIGame(rows, cols, block, sleep);
  ai.setBackground(Color.black);
  f.add(ai);
  f.pack();
  f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
  f.setResizable(false);
  f.setVisible(true);
  new Thread(ai).start();
 }
}

No próximo post iremos implementar a GA e observar a evolução dos agentes. Caso esteja curioso, substitua os genes por esses valores (na ordem): 0.95856917f, 0.08560386f, 0.9476838f, 0.58525276f,0.0291457f, 0.18112998f, 0.17345366f e execute o programa algumas vezes. Até a próxima!

Nenhum comentário:

Postar um comentário