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).

javascript
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.

javascript
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!