coding

Criando uma AI mais inteligente e performática no Phaser JS - Game Devlog #15

Escrito em 15 de dezembro de 2020 - 🕒 5 min. de leitura

Vamos lá, se prepare pois o devlog de hoje é longo, mas vale a pena. Eu estou muito feliz que eu consegui fazer esse devlog, afinal Cyberpunk 2077 saiu essa semana e eu comecei a jogar no Stadia! O devlog de hoje está longo porque quero explicar o motivo pelo qual estou fazendo certas coisas.

Hoje vou mostrar como melhorei a AI dos inimigos do jogo e por que lido com tantos edge cases no meu código. Bora lá!

Minha primeira tentativa no algoritmo de AI foi calculá-lo em tempo de execução, então onde quer que eu colocasse um inimigo, ele se moveria e calcularia se deveria girar ou não em cada frame, isso era muito pesado para o processador, especialmente quando eu tinha muitos inimigos na tela ao mesmo tempo.

Havia duas maneiras de abordar esse problema, uma seria criar manualmente um path estático no Tiled e fazer o inimigo seguir esse path, mas sempre que adicionar um novo inimigo, eu teria que adicionar um path para ele também, e isso pode acaba sendo um trabalho super tedioso, sem contar às vezes que eu com certeza esqueceria de adicionar o path. Complicado.

A segunda opção é ao invés de calcular o comportamento do inimigo em cada frame, eu poderia pré-calcular todos eles assim que a fase fosse carregada, e então definir esse path estaticamente, desta forma eu teria a liberdade de adicionar inimigos onde eu quisesse no Tiled, sem ter que adicionar paths extras para eles seguirem e também manter a taxa de frames estável por não processar a lógica do inimigo em cada frame.

Então, como eu fiz isso? Primeiro, há alguns edge cases que eu preciso lidar, tipo e se o inimigo não estiver em uma plataforma ou chão? Ou e se a plataforma não tiver bordas? Eu sei que eu mesmo estarei fazendo as fases no Tiled, mas é bom lidar com esses problemas de qualquer forma, porque nunca sabemos quais bugs o futuro nos reserva.

Já que eu não tenho como saber se o inimigo já está em uma plataforma ou não, eu preciso esperar a física do jogo entrar em ação e calcular a posição do inimigo, como à gravidade e outras coisas, então ao invés de calcular a AI estática no construtor, estou fazendo isso na função update e, depois de calculado, simplesmente “removo” a função update substituindo-a por uma função no-op (no operation).

class Enemy extends GameObjects.Sprite {
    constructor({ scene, x, y, asset }) {
        super(scene, x, y, asset);
        this.staticAi = [PLATFORM_WALKING];
        this.aiColliders = [];
    }

    update(time, delta) {
        if (this.aiColliders.length) {
            // we don't need the update function anymore
            this.update = () => {};
            return;
        }

        this.staticAi.forEach((aiStrategy) => {
            if (aiStrategy === PLATFORM_WALKING) {
                // first check if the enemy is on a platform
                if (this.touchingDownObject?.layer?.tilemapLayer) {
                    // then calculate the colliders that will make the enemy change direction
                    const colliders = staticPlatformWalkingBehavior(
                        this,
                        this.touchingDownObject.layer.tilemapLayer
                    );

                    // TODO concat array
                    colliders.forEach((collider) => this.aiColliders.push(collider));
                }
            }
        });
    }
}

export default Enemy;

E então toda a mágica acontece dentro do staticPlatformWalkingBehavior, que honestamente é super simples e zero mágica, eu só preciso pegar a layer de colisão do inimigo e verificar se há uma borda na plataforma em ambas as direções. O único problema dessa função é que não quero que ela rode para sempre até encontrar uma borda, porque talvez a plataforma não tenha uma borda, quem sabe? Então, em vez disso, executarei esse loop para cada tile que eu tenho dentro da largura do mapa.

export function staticPlatformWalkingBehavior(
    gameObject,
    dynamicLayer
) {
    const { scene } = gameObject;
    const divider = TILE_WIDTH;
    const loopSize = scene.mapData.tileSize.width * divider;
    const result = [];
    const layers = dynamicLayer?.getChildren?.() || [dynamicLayer];

    // for each collision layer
    layers.forEach((layer) => {
        // for each direction
        [FACING_RIGHT, FACING_LEFT].forEach((direction) => {
            // for each tile of the map * divider
            let lastGroundTile;
            new Array(loopSize).fill(null).some(
                (num, index) => {
                    const multiplier = direction === FACING_RIGHT ? 1 : -1;
                    const posX = gameObject.x + (((index + 1) - 0.5) * multiplier);
                    const groundTile = layer.getTileAtWorldXY(
                        posX,
                        gameObject.y + 0.5
                    );

                    const tile = layer.getTileAtWorldXY(
                        posX,
                        gameObject.y - 0.5
                    );

                    // check for collisions
                    if (
                        !groundTile?.properties?.collideUp
                        || (direction === FACING_RIGHT && tile?.properties?.collideLeft)
                        || (direction === FACING_LEFT && tile?.properties?.collideRight)
                    ) {
                        let newPosX =
                            lastGroundTile?.pixelX
                            || tile?.pixelX
                            || Math.round(posX / TILE_WIDTH) * TILE_WIDTH;
                        newPosX += (TILE_WIDTH * multiplier);

                        // create a custom collider object
                        const platformLimitCollider = createInteractiveGameObject(
                            gameObject.scene,
                            newPosX,
                            gameObject.y - TILE_HEIGHT,
                            TILE_WIDTH,
                            TILE_HEIGHT,
                            'something'
                        );

                        // create a collision between the gameobject and the custom collider
                        scene.physics.add.collider(gameObject, platformLimitCollider, () => {
                            gameObject.body.setVelocityX(
                                -gameObject.body.velocity.x
                            );
                            gameObject.setFlipX(!gameObject.flipX);
                        });
                        result.push(platformLimitCollider);

                        // return true so the 'some' doesn't run anymore
                        return true;
                    }

                    lastGroundTile = groundTile;
                    return false;
                }
            );
        });
    });

    return result;
}

E com esse código, temos o resultado abaixo.

Cálculo estático da AI

O meu principal objetivo é criar todas as regras de jogo baseadas em elementos e interações para que depois eu possa simplesmente criar uma fase no Tiled e tudo funcionar sem eu precisar ficar mexendo muito nela, criando colliders e outras coisas manualmente, assim como fizeram no Breath of The Wild. Eu prefiro que o jogo calcule tudo isso pra mim para que eu possa focar em criar novas fases sem dificuldade. E no final eu vou ter tipo um Mario Maker 😃.

E por hoje ficamos por aqui, obrigado por ler e assistir o vídeo e não se esqueça de deixar o seu comentário sobre o que você está achando dessa série de posts / vídeos. Até semana que vem!

Tags:


Publicar um comentário

Comentários

Nenhum comentário.