Creating a smarter and more performing AI on Phaser JS - Game Devlog #15

Written in December 15, 2020 - Games

Alright, get comfortable because today’s devlog is long, but it’s worth it. I’m really happy I managed to do this devlog, after all Cyberpunk 2077 was released this week, and I’m playing it on Stadia! Today’s devlog is long mostly because I want to explain the reason why I am doing things a certain way.

Today I’m going to show you how I improved my enemy AI and why I handle so many edge cases in my code. Let’s dive into it.

My first take on the AI algorithm was to calculate it on the go, so wherever I place an enemy, it would move around and calculate if it should turn or not on every frame, this was very costly, specially when I had many enemies on screen.

There were two ways to approach this problem, one would be manually creating a static path on Tiled and make the enemy follow that path, but whenever I add a new enemy, I would have to add a path for it too, and this can get really annoying to do really fast.

The second option is instead of calculating the enemy’s behaviour on every frame, I could pre-calculate all of them as soon as the stage is loaded and then set that path statically, this way I would have the freedom to add enemies wherever I want on Tiled, without having to add extra paths for them to follow and also keep the framerate stable by not processing the enemy’s logic on every frame.

So how did I do that? First there is some edge cases that I needed to handle, like what if the enemy is not on a platform? Or what if the platform don’t have edges? I know I will be making the stages on Tiled, but it’s good to handle those anyway because we never know.

Since I can’t know if the enemy is already in a platform or not, I need to wait for the game’s physics to kick in and calculate the enemy’s position, such as gravity and stuff, so instead of calculating the static AI in the constructor, I’m doing it in the update function, and after it’s calculated, I simply “remove” the update function by replacing it with a no-op function.

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;

And then all the magic happens inside the staticPlatformWalkingBehavior, which honestly is super straight forward, I just need to get the collision layer from the enemy and check for an edge in the platform in both directions. The only catch in this function is that I don’t want it to run forever until it finds and edge, because maybe the platform doesn’t have an edge, who knows? So instead I will run the loop for every tile in the map’s width.

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;
}

And with that code, we have the result below.

Static AI calculation

My main goal is to create all the rules of the game based on elements and interactions so that afterwards I can simply create a stage on Tiled and everything works without having to mess around with it, creating colliders and other things manually, just like how Breath of The Wild was made. I much rather that the game calculate it all for me, so I can focus on creating levels. In the end I’ll have something like a Mario Maker 😃.

And that was all for today, thanks for reading and watching the video and don’t forget to leave your comment about what you are thinking about this series of posts / videos. Until next week!

Tags:


Post a comment

Comments

No comments yet.