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