Tactics RPG: movimentação baseada em turnos

Um dos meus estilos de jogos favoritos é o "turn-based tactics RPG", que consiste em batalhas onde cada personagem (ou tipo de personagem) tem características e habilidades diferentes, que sendo bem exploradas, podem garantir a vitória. Além disso, existem as táticas "militares", que são a essência do jogo. Por exemplo: uma boa formação tática consiste em posicionar a infantaria na vanguarda e a artilharia, que não tem uma boa defesa, na retaguarda, de onde pode disparar projéteis nos inimigos em segurança.

Jogos desse tipo são mais ou menos como jogos de tabuleiro, mas sem a chatice de ficar escrevendo em fichas de papel ou guardar todas as pecinhas no final do jogo. Mesmo assim, há quem ache esse tipo de jogo chato, e realmente, tem horas que fica chato, mas depois passa. :)

Você já ouviu falar da série Final Fantasy Tactics ou Fire Emblem? São exemplos de jogos desse gênero. Veja abaixo o gameplay de Final Fantasy Tactics Advance 2 e entenda melhor:

Observe o que acontece quando um personagem é selecionado: uma área azul aparece. Ela marca quais os tiles (posições no cenário) para onde esse personagem pode se mover ou atacar. Essa área é influenciada pelo relevo, pelos personagens próximos e os atributos do próprio personagem, entre outros fatores. E o algoritmo que cria essa área não é tão complicado. Veja abaixo como ele funciona:

O grid

Primeiro é preciso criar uma matriz, que vai representar o cenário do jogo. Neste exemplo criei uma matriz 6 x 5 e populei com zeros. Eles representam tiles vazios, enquanto outros números representam obstáculos. Para esse exemplo vou considerar que não existem obstáculos:

    
var grid = [
    [0,0,0,0,0,0],
    [0,0,0,0,0,0],
    [0,0,0,0,0,0],
    [0,0,0,0,0,0],
    [0,0,0,0,0,0]
];
    

E graficamente, essa matriz pode ser representada da seguinte forma, com o personagem no tile [2,2] (veja a referência no gráfico adjacente):

Podemos agora adicionar obstáculos, como pedras, água, ou outros personagens, que nesse exemplo simples serão representados pelo valor "1". Em casos mais complexos, o tipo de terreno, como deserto e pântano, também pode influenciar negativamente na movimentação. Isso também pode variar de acordo com o tipo de personagem. Por exemplo, personagens montados em cavalos podem ter sua movimentação reduzida em terrenos arenosos (ver Shinning Force 2). Já personagens voadores não sofrem nenhuma influência. Legal, hein? :)

    
var grid = [
    [0,0,1,1,1,1],
    [0,0,0,0,1,1],
    [1,0,0,0,0,1],
    [0,0,1,0,0,0],
    [0,0,0,0,0,0]
];
    

Assim:

Onde os tiles com "1" não podem ser acessados.

A posição

A posição na matriz é dada pelos valores X e Y no grid, de acordo com o primeiro gráfico acima. Então, o personagem está na posição (2,2). Esses dois valores são passados como parâmetros para o algoritmo, que vai colorir a área de acordo com eles. Mas só isso não basta. O algoritmo precisa saber quando parar de colorir os tiles.

O personagem selecionado deve possuir atributos, como força, defesa, etc. O atributo necessário para nosso algoritmo funcionar é o movimento, que vai determinar o quão grande deve ser a área selecionada. Vamos supor dois casos em que esse personagem tenha um movimento igual a 1, e 2, respectivamente. A áreas serão coloridas da seguinte forma:

O algoritmo deve saber quais tiles deve colorir, tendo como base apenas a posição e o atributo Movimento do nosso personagem, seguindo o padrão "losango" acima. Ele também deve evitar casos como o ilustrado abaixo, com obstáculos:

O código

Uma das possíveis soluções para este problema é:

  1. Começando a partir do tile onde o personagem está, verificar se é acessível e está dentro do limite do cenário.
  2. Repetir o processo acima para os quatro tiles adjacentes, e assim por diante, até atingir a área máxima.

Vamos criar uma função recursiva (i.e. chama a si mesma) para essa tarefa. Ela vai receber os valores x, y e mov como parâmetros.

    function tile_walk(x, y, mov) { }

Agora escreveremos os testes para assegurar que os tiles corretos sejam selecionados. O primeiro teste verifica se o tile atual está além dos limites do cenário, onde WIDTH e HEIGHT armazenam os valores 6 e 5, respectivamente, no nosso exemplo:

    if (0 > tile.x >= WIDTH || 0 > tile.y >= HEIGHT)
    return;

O segundo teste simplesmente verifica se o tile está livre. Obviamente, essa regra depende muito do jogo.

    if (tile.blocked)
    return;

Finalmente, um último teste para saber se o tile atual ainda não está na lista de tiles "acessados", o que significa que ele ainda não foi verificado pelo algoritmo. Se não estiver, o adiciona. Neste exemplo, essa lista serve apenas para lembrar quais tiles foram marcados para depois apagá-los quando uma nova área precisar ser selecionada. Logo após, chama-se a função tile_walk para os quatro tiles adjacentes. Os testes acima serão aplicados novamente para cada um deles, e assim sucessivamente, até que alguma das condições não seja atendida.

E finalmente, o código completo e a demonstração:

    
function tile_walk(x, y, mov) {
    if (0 > tile.x >= WIDTH || 0 > tile.y >= HEIGHT)
        return;

    if (tile.blocked)
        return;

    if (tile not in open_tiles ) {
        open_tiles.insert(tile);
        
        tile_walk(x+1, y, mov-1);
        tile_walk(x-1, y, mov-1);
        tile_walk(x, y+1, mov-1);
        tile_walk(x, y-1, mov-1);
    }
}

tile_walk(X, Y, MOV);

Demonstração em Javascript

O código dessa demonstração difere em alguns pontos, mas não altera a ideia. Clique nos tiles para testar e sinta-se livre para baixar e alterar o código. Se você tiver uma solução melhor e ainda didática (ou seja, não-críptica), entre em contato ;)

Movimento (1 a 7):

Revisões

Contribuíram com esse tutorial: Daniel Luz (github), Vitor e Henrique Lima.

Referências

The Game Programming Wiki