coding

Como criar um jogo estilo RPG Maker com Phaser e React

Escrito em 8 de outubro de 2021 - 🕒 16 min. de leitura

A minha primeira experiência com “programação” foi em 2002, quando eu fazia jogos com o RPG Maker, foi quando eu aprendi o que era uma variável, persistência de dados e loops.

Eu nunca cheguei a publicar nenhum dos jogos que eu fiz, mas meio que sempre foi o meu sonho então agora que eu ja tenho cerca de 2 anos de experiência com Phaser, resolvi fazer um pequeno jogo no mesmo estilo dos de RPG Maker que eu fazia em 2002.

Integrando React com Phaser

O Phaser funciona renderizando píxeis dentro de um elemento canvas no DOM, e isso é ótimo, mas um dos grandes recursos do desenvolvimento da web é o poder do DOM e CSS para criar excelentes elementos de UI, e você não pode fazer isso com Phaser sozinho.

A maneira mais simples de usar o Phaser com React é com um componente funcional como o mostrado abaixo.

import React from 'react';
import Phaser from 'phaser';

function GameComponent() {
    const game = new Phaser.Game({
        ...configs,
        parent: 'game-content',
    });

    return <div id="game-content" />;
};

export default GameComponent;

Usando React para a UI do jogo

Para fazer o Phaser ser capaz de se comunicar com o React, por exemplo, para mostrar um item de menu ou uma caixa de diálogo, vou despachar eventos do JavaScript entre o Phaser e o React usando o new CustomEvent('event-name').

A melhor maneira de fazer isso seria com algum tipo de ferramenta de gestão de estado, como o Flux, mas como este é apenas um projeto muito pequeno, despachar eventos do JavaScript funcionará por agora.

// React component
function GameComponent() {
    const [messages, setMessage] = useState('');
    const [showDialogBox, setShowDialogBox] = useState(false);

    useEffect(() => {
        const dialogBoxEventListener = ({ detail }) => {
            setMessage(detail.message);
            setShowDialogBox(true);
        };
        window.addEventListener('start-dialog', dialogBoxEventListener);

        return () => {
            window.removeEventListener('start-dialog', dialogBoxEventListener);
        };
    });

    const handleMessageIsDone = useCallback(() => {
        const customEvent = new CustomEvent('end-dialog');
        window.dispatchEvent(customEvent);

        setMessage('');
        setShowDialogBox(false);
    }, [characterName]);

    return (
        <>
            {showDialogBox && (
                <DialogBox
                    message={message}
                    onDone={handleMessageIsDone}
                />
            )}
            <div id="game-content" />
        </>
    );
};

// Phaser scene
class GameScene extends Phaser.Scene {
    constructor() {
        super('GameScene');
    }

    create() {
        const dialogBoxFinishedEventListener = () => {
            window.removeEventListener('end-dialog', dialogBoxFinishedEventListener);
            // Do whatever is needed when the dialog is over
        };
        window.addEventListener('end-dialog', dialogBoxFinishedEventListener);
    }
}

Caixa de dialogo

Se você quiser mais detalhes sobre como criar uma caixa de diálogo React para o seu jogo em Phaser, leia o meu post sobre o assunto.

Usando o grid-engine e carregando mapas do Tiled

Para criar o mapa eu vou novamente usar o Tiled, que é um FOSS no qual você pode criar mapas e usá-los em praticamente qualquer game engine.

O tileset que estou usando para o meu mapa é o Zelda-like tileset criado por ArMM1998, que inclui tilesets para interiores, tipo casas, e exteriores, tipo cidades.

Depois que o mapa estiver pronto, vou embutir o tileset ao mapa, e então posso simplesmente importar o mapa e o arquivo do tileset no meu jogo e carregá-los no Phaser com this.load.tilemapTiledJSON() e this.load.image().

import mainMapaJson from '../../../content/assets/game/maps/main-map.json';
import tilesetImage from '../../../content/assets/game/maps/tileset.png';

class GameScene extends Phaser.Scene {
    constructor() {
        super('GameScene');
    }

    preload() {
        this.load.tilemapTiledJSON('main-map', mainMapaJson);
        this.load.image('tileset', tilesetImage);
    }
}

Para adicionar o grid-engine no Phaser, basta configurar o seu jogo para usar o arcade physics e adicionar o grid-engine como plugin, como on código abaixo.

const game = new Phaser.Game({
    ...configs,
    parent: 'game-content',
    physics: {
        default: 'arcade',
    },
    plugins: {
        scene: [{
                key: 'gridEngine',
                plugin: GridEngine,
                mapping: 'gridEngine',
            }],
    },
});

Agora eu posso acessar o plugin através da propriedade this.gridEngine dentro de qualquer game scene do jogo. O próximo passo é criar um sprite e movê-lo com o plugin do grid-engine.

create() {
    const map = this.make.tilemap({ key: 'main-map' });
    map.addTilesetImage('tileset', 'tileset');
    map.layers.forEach((layer, index) => {
        map.createLayer(index, 'tileset', 0, 0);
    });

    const heroSprite = this.physics.add.sprite(0, 0, 'hero');

    const gridEngineConfig = {
        characters: [{
            id: 'hero',
            sprite: heroSprite,
            startPosition: { x: 1, y: 1 },
        }],
    };
    this.gridEngine.create(map, gridEngineConfig);
}

update() {
    const cursors = this.input.keyboard.createCursorKeys();

    if (cursors.left.isDown) {
        this.gridEngine.move('hero', 'left');
    } else if (cursors.right.isDown) {
        this.gridEngine.move('hero', 'right');
    } else if (cursors.up.isDown) {
        this.gridEngine.move('hero', 'up');
    } else if (cursors.down.isDown) {
        this.gridEngine.move('hero', 'down');
    }
}

Este código foi praticamente copiado e colado da documentação oficial do plugin.

Para fazer as colisões funcionarem automaticamente com um mapa criado no Tiled, basta adicionar a propriedade ge_collide com o valor true nos tiles com os quais o herói deve colidir.

Observe que isso fará com que o herói se mova e colida com objetos, mas não há nenhuma animação no sprite. Para criar novas animações para o sprite, usarei a função this.anims.create() e farei as animações rodarem toda vez que o grid-engine me informar que o herói se moveu, com os eventos movementStarted, movementStopped e directionChanged despachados pelo plugin.

create() {
    const heroSprite = this.physics.add.sprite(0, 0, 'hero');
    this.createHeroWalkingAnimation('up');
    this.createHeroWalkingAnimation('right');
    this.createHeroWalkingAnimation('down');
    this.createHeroWalkingAnimation('left');

    this.gridEngine.create(map, gridEngineConfig);
    this.gridEngine.movementStarted().subscribe(({ direction }) => {
        heroSprite.anims.play(direction);
    });

    this.gridEngine.movementStopped().subscribe(({ direction }) => {
        heroSprite.anims.stop();
        heroSprite.setFrame('down_01');
    });

    this.gridEngine.directionChanged().subscribe(({ direction }) => {
        heroSprite.setFrame('down_01');
    });
}

createHeroWalkingAnimation(direction) {
    this.anims.create({
        key: direction,
        frames: [
            { key: 'hero', frame: `${direction}_01` },
            { key: 'hero', frame: `${direction}_02` },
        ],
        frameRate: 4,
        repeat: -1,
        yoyo: true,
    });
}

Criando interações para o herói

O herói deve ser capaz de interagir com o mundo, por exemplo, chegando perto de um NPC e conversando com ele.

Parece simples, mas como conseguir isso com o Phaser? Existem muitas maneiras de fazer isso, mas a mais fácil é criar um custom collider que estará sempre na frente do herói, e esse custom collider irá interagir com todos os objetos no jogo.

Primeiro vou criar um objeto usando a classe Phaser.GameObjects.Rectangle:

this.heroActionCollider =
    new GameObjects.Rectangle(this, 0, 0, 14, 8).setOrigin(0, 1);
this.physics.add.existing(this.heroActionCollider);
this.heroActionCollider.body.setImmovable(true);

Agora preciso fazer com que esse custom collider fique sempre na parte da frente do herói, para isso vou usar a função update da minha game scene.

update() {
    const facingDirection = this.gridEngine.getFacingDirection('hero');

    switch (facingDirection) {
        case 'down': {
            this.heroActionCollider.setSize(14, 8);
            this.heroActionCollider.body.setSize(14, 8);
            this.heroActionCollider.setX(this.heroSprite.x + 9);
            this.heroActionCollider.setY(this.heroSprite.y + 36);

            break;
        }

        case 'up': {
            this.heroActionCollider.setSize(14, 8);
            this.heroActionCollider.body.setSize(14, 8);
            this.heroActionCollider.setX(this.heroSprite.x + 9);
            this.heroActionCollider.setY(this.heroSprite.y + 12);

            break;
        }

        case 'left': {
            this.heroActionCollider.setSize(8, 14);
            this.heroActionCollider.body.setSize(8, 14);
            this.heroActionCollider.setX(this.heroSprite.x);
            this.heroActionCollider.setY(this.heroSprite.y + 21);

            break;
        }

        case 'right': {
            this.heroActionCollider.setSize(8, 14);
            this.heroActionCollider.body.setSize(8, 14);
            this.heroActionCollider.setX(this.heroSprite.x + 24);
            this.heroActionCollider.setY(this.heroSprite.y + 21);

            break;
        }

        default: {
            // will never happen
            break;
        }
    }
}

Adicionando tiles interativos

Agora que tenho a variável this.heroActionCollider, vamos tornar o mundo um pouco mais interativo. Eu quero que o herói seja capaz de cortar grama e empurrar caixas, então primeiro vou criar uma layer chamada test no meu mapa e adicionar os tiles da grama e da caixa.

map.layers.forEach((layer, index) => {
    map.createLayer(index, 'tileset', 0, 0);

    if (layer.name === 'test') {
        layer.data.flat().forEach((tile) => console.log(tile.index));
    }
});

No meu caso, o index da grama é 428, e o index da caixa é 427, e com essas informações eu já posso começar a fazer o meu código.

Primeiro, vou criar um GameGroup com a função this.add.group() e adicionar as minhas layers interativas nele e, em seguida, criar um overlap entre this.heroActionCollider e interactiveLayers com a função this.physics.add.overlap().

const interactiveLayers = this.add.group();
map.layers.forEach((layer, index) => {
    map.createLayer(index, 'tileset', 0, 0);

    if (layer.name === 'interactions') {
        interactiveLayers.add(layer);
    }
});

const GRASS_INDEX = 428;
const BOX_INDEX = 427;

this.physics.add.overlap(this.heroActionCollider, interactiveLayers, (objA, objB) => {
    const tile = [objA, objB].find((obj) => obj !== this.heroActionCollider);

    if (tile?.index > 0 && !tile.wasHandled) {
        switch (tile.index) {
            case GRASS_INDEX: {
                tile.wasHandled = true;
                break;
            }

            case BOX_INDEX: {
                tile.wasHandled = true;
                break;
            }

            default: {
                break;
            }
        }
    }
});

Para a ação de cortar a grama, vou verificar se a tecla ESPAÇO está pressionada com Input.Keyboard.JustDown() e, se estiver, destruirei aquele tile com tile.destroy().

this.spaceKey = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.SPACE);

this.physics.add.overlap(this.heroActionCollider, interactiveLayers, (objA, objB) => {
    const tile = [objA, objB].find((obj) => obj !== this.heroActionCollider);

    if (tile?.index > 0 && !tile.wasHandled) {
        switch (tile.index) {
            case GRASS_INDEX: {
                if (Input.Keyboard.JustDown(this.spaceKey)) {
                    tile.wasHandled = true;

                    // Wait a little bit for the attack animation to be over
                    this.time.delayedCall(
                        50,
                        () => {
                            tile.setVisible(false);
                            tile.destroy();
                        }
                    );
                }

                break;
            }
        }
    }
});

Para a ação de empurrar a caixa, também irei verificar se a tecla ESPAÇO está pressionada, e então mover o tile para a sua nova posição com this.tweens.add().

this.spaceKey = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.SPACE);

this.physics.add.overlap(this.heroActionCollider, interactiveLayers, (objA, objB) => {
    const tile = [objA, objB].find((obj) => obj !== this.heroActionCollider);

    if (tile?.index > 0 && !tile.wasHandled) {
        switch (tile.index) {
            case BOX_INDEX: {
                if (Input.Keyboard.JustDown(this.spaceKey)) {
                    tile.wasHandled = true;
                    const newPosition = this.calculatePushedTilePosition();

                    this.tweens.add({
                        targets: tile,
                        pixelX: newPosition.x,
                        pixelY: newPosition.y,
                        ease: 'Power2',
                        duration: 700,
                        onComplete: this.handlePushedTileCompleted(tile),
                    });
                }

                break;
            }
        }
    }
});

Este código contém 2 funções misteriosas 🕵️, calculPushedTilePosition e handlePushedTileCompleted. A primeira irá calcular a nova posição do tile depois dele ser empurrado com base na posição do herói.

calculatePushedTilePosition() {
    const facingDirection = this.gridEngine.getFacingDirection('hero');
    const position = this.gridEngine.getPosition('hero');

    switch (facingDirection) {
        case 'up':
            return {
                x: position.x * this.tileWidth,
                y: (position.y - 2) * this.tileHeight,
            };

        case 'right':
            return {
                x: (position.x + 2) * this.tileWidth,
                y: position.y * this.tileHeight,
            };

        case 'down':
            return {
                x: position.x * this.tileWidth,
                y: (position.y + 2) * this.tileHeight,
            };

        case 'left':
            return {
                x: (position.x - 2) * this.tileWidth,
                y: position.y * this.tileHeight,
            };

        default:
            return {
                x: position.x * this.tileWidth,
                y: position.y * this.tileHeight,
            };
    }
}

A função handlePushedTileCompleted irá remover o tile empurrado e criar um novo na nova posição. Isso precisa ser feito porque, embora a posição do tile tenha mudado com a animação da função this.tweens.add(), a colisão permanece na posição original do tile, não sei se isso é por design, ou se é um bug no Phaser ou no plugin grid-engine.

handlePushedTileCompleted(tile) {
    const newTile = tile.layer.tilemapLayer.putTileAt(
        tile.index,
        newPosition.x / this.tileWidth,
        newPosition.y / this.tileHeight,
        true
    );

    newTile.properties = {
        ...tile.properties,
    };
    newTile.wasHandled = true;

    tile.setVisible(false);
    tile.destroy();
}

Cortando grama

Adicionando inimigos

Para adicionar inimigos no mapa, vou usar a layer de objetos do Tiled, que pode ser acessada através da função map.getObjectLayer(). Primeiro eu vou criar uma layer de objeto chamada elements e adicionar alguns tiles com o botão “Insert Tile”.

Insert a tile
Insert a tile

Também vou adicionar uma propriedade personalizada com o nome enemyData a este tile com o valor slime_red:follow:1:100, que serão todas as propriedades do meu inimigo, estes valores separados por ”:” são tipoDeInimigo:tipoDeAI:velocidade:vida. Para obter esses valores, vou dividir a string e adicionar todos esses valores em uma array para usar mais tarde.

const enemiesData = [];
const dataLayer = map.getObjectLayer('elements');
dataLayer.objects.forEach((data) => {
    const { properties, x, y } = data;

    properties.forEach((property) => {
        const { name, type, value } = property;

        switch (name) {
            case 'enemyData': {
                const [enemyType, enemyAI, speed, health] = value.split(':');
                enemiesData.push({
                    x,
                    y,
                    speed: Number.parseInt(speed, 10),
                    enemyType,
                    enemySpecies: this.getEnemySpecies(enemyType),
                    enemyAI,
                    enemyName: `${enemyType}_${enemiesData.length}`,
                    health: Number.parseInt(health, 10),
                });

                break;
            }

            default: {
                break;
            }
        }
    });
});

Agora a variável enemiesData contém todos os dados de todos os inimigos no mapa, então tudo o que me resta é criar sprites inimigos com this.physics.add.sprite() e adicioná-los a array gridEngineConfig.characters.

this.enemiesSprites = this.add.group();
enemiesData.forEach((enemyData, index) => {
    const { enemySpecies, enemyType, x, y, enemyName, speed, enemyAI, health } = enemyData;
    const enemy = this.physics.add.sprite(0, 0, enemyType);

    if (enemyType.includes('red')) {
        enemy.setTint(0xF1374B);
    } else if (enemyType.includes('green')) {
        enemy.setTint(0x2BBD6E);
    } else if (enemyType.includes('yellow')) {
        enemy.setTint(0xFFFF4F);
    } else if (enemyType.includes('blue')) {
        enemy.setTint(0x00A0DC);
    }

    enemy.name = enemyName;
    enemy.enemyType = enemyType;
    enemy.enemySpecies = enemySpecies;
    enemy.enemyAI = enemyAI;
    enemy.speed = speed;
    enemy.health = health;

    this.enemiesSprites.add(enemy);

    gridEngineConfig.characters.push({
        id: enemyName,
        sprite: enemy,
        startPosition: { x: x / this.tileWidth, y: (y / this.tileHeight) },
        speed,
    });
});

Para fazer o inimigo seguir o herói, vou simplesmente usar a função this.gridEngine.follow().

Adicionando itens

Para os itens eu vou novamente usar a layer de objetos do Tiled que eu já criei, chamada elements. Por enquanto, eu vou adicionar dois itens diferentes no jogo, moedas e corações, usando uma propriedade personalizada com o nome itemData.

this.itemsSprites = this.add.group();
const dataLayer = map.getObjectLayer('elements');
dataLayer.objects.forEach((data) => {
    const { properties, x, y } = data;

    properties.forEach((property) => {
        const { name, type, value } = property;

        switch (name) {
            case 'itemData': {
                // it's only one value... for now.
                const [itemType] = value.split(':');

                switch (itemType) {
                    case 'coin': {
                        const item = this.physics.add
                            .sprite(x, y, 'coin')
                            .setDepth(1)
                            .setOrigin(0, 1);

                        item.itemType = 'coin';
                        this.itemsSprites.add(item);
                        break;
                    }

                    case 'heart': {
                        const item = this.physics.add
                            .sprite(x, y, 'heart')
                            .setDepth(1)
                            .setOrigin(0, 1);

                        item.itemType = 'heart';
                        this.itemsSprites.add(item);
                        break;
                    }

                    default: {
                        break;
                    }
                }

                break;
            }

            default: {
                break;
            }
        }
    });
});

Agora eu só preciso criar um overlap entre o herói e o grupo de itens na variável this.itemsSprites.

this.physics.add.overlap(this.heroSprite, this.itemsSprites, (objA, objB) => {
    const item = [objA, objB].find((obj) => obj !== this.heroSprite);

    if (item.itemType === 'heart') {
        this.heroSprite.restoreHealth(2);
        item.setVisible(false);
        item.destroy();
    }

    if (item.itemType === 'coin') {
        this.heroSprite.collectCoin(1);
        item.setVisible(false);
        item.destroy();
    }
});

Usando React para o HUD

No código do overlap com os itens, eu não expliquei como as funções restoreHealth e collectCoin funcionam, pois, elas se comunicam diretamente com o React, então primeiro vamos ver como é esse componente.

O componente de HUD em React é bastante simples e contém 3 props, um integer screenWidth, para o posicionamento do HUD na tela, um integer coins, para mostrar a quantidade de moedas coletadas, e uma array healthState, com a quantidade de corações e se eles estão vazios, cheios ou pela metade.

import React from 'react';
import coinImage from '../images/coin.png';
import healthImage from '../images/health.png';

const useStyles = makeStyles((theme) => ({
    hudContainer: ({ screenWidth }) => {
        const left = window.innerWidth - screenWidth;
        return {
            fontFamily: '"Press Start 2P"',
            fontSize: '12px',
            textTransform: 'uppercase',
            imageRendering: 'pixelated',
            position: 'absolute',
            top: '16px',
            left: `${16 + (left / 2)}px`,
            display: 'flex',
            cursor: 'default',
            userSelect: 'none',
        };
    },
    health: () => ({
        width: '16px',
        height: '16px',
    }),
    healthStateFull: () => ({
        backgroundSize: '48px 16px',
        background: `url("${healthImage}") no-repeat 0 0`,
    }),
    healthStateHalf: ({ healthState }) => ({
        backgroundSize: '48px 16px',
        background: `url("${healthImage}") no-repeat -16px 0`,
    }),
    healthStateEmpty: ({ healthState }) => ({
        backgroundSize: '48px 16px',
        background: `url("${healthImage}") no-repeat -32px 0`,
    }),
    coin: () => ({
        backgroundSize: '16px 16px',
        background: `url("${coinImage}") no-repeat 0 0`,
        width: '16px',
        height: '16px',
    }),
    coinFull: () => {
        return {
            fontSize: '11px',
            textShadow: `-1px 0 #FFFFFF, 0 1px #FFFFFF, 1px 0 #FFFFFF, 0 -1px #FFFFFF`,
            color: '#119923',
        };
    },
}));

function GameHeadsUpDisplay({ screenWidth, coins, healthState }) {
    const classes = useStyles({
        screenWidth,
    });
    
    return (
        <div className={classes.hudContainer}>
            <div className={classes.healthContainer}>
                {healthStates.map((healthState, index) => (
                    <div
                        key={index}
                        className={classNames(classes.health, {
                            [classes.healthStateFull]: healthState === 'full',
                            [classes.healthStateHalf]: healthState === 'half',
                            [classes.healthStateEmpty]: healthState === 'empty',
                        })}
                    />
                ))}
            </div>
            <div className={classes.coinContainer}>
                <div className={classes.coin} />
                <span
                    className={classNames({
                        [classes.coinFull]: coins >= 999,
                    })}
                >
                    {coins.toString().padStart(3, '0')}
                </span>
            </div>
        </div>
    );
}

export default GameHeadsUpDisplay;

O código da função collectCoin apenas envia um evento JavaScript com o número de moedas que o herói possui.

collectCoin(heroCoins) {
    const customEvent = new CustomEvent('hero-coin', {
        detail: {
            heroCoins,
        },
    });

    window.dispatchEvent(customEvent);
}

A função restoreHealth é mais complexa, pois precisa lidar com a possibilidade de corações vazios, cheios, e pela metade.

restoreHealth(healthToRestore) {
    const healthStates = Array.from({ length: this.heroSprite.maxHealth / 2 })
        .fill(null).map(
            (value, index) => {
                const health = Math.max(this.heroSprite.health - (2 * index), 0);
                if (health > 1) {
                    return 'full';
                }

                if (health > 0) {
                    return 'half';
                }

                return 'empty';
            }
        );

    const customEvent = new CustomEvent('hero-health', {
        detail: {
            healthStates,
        },
    });

    window.dispatchEvent(customEvent);
}

Bom, pensando bem, ela nem é tão complexa assim, mas eu demorei um pouco para fazer tudo funcionar direitinho. hehe.

O component GameComponent ficou da seguinte forma com o HUD:

function GameComponent() {
    const [heroCoins, setHeroCoins] = useState(null);
    const [heroHealthStates, setHeroHealthStates] = useState([]);

    useEffect(() => {
        const heroCoinEventListener = ({ detail }) => {
            setHeroCoins(detail.heroCoins);
        };
        window.addEventListener('hero-coin', heroCoinEventListener);

        const heroHealthEventListener = ({ detail }) => {
            setHeroHealthStates(detail.healthStates);
        };
        window.addEventListener('hero-health', heroHealthEventListener);

        return () => {
            window.removeEventListener('hero-coin', heroCoinEventListener);
            window.removeEventListener('hero-health', heroHealthEventListener);
        };
    });

    return (
        <>
            <GameHeadsUpDisplay
                coins={heroCoins}
            />
            <div id="game-content" />
        </>
    );
};

Teleportando para outro mapa

Todo o código feito até agora funciona apenas para um mapa, o main-map, porém o meu jogo tem vários mapas, como casas, cidades e o overworld. Imagina ter que copiar todo esse código para outras game scenes 😵.

Ao invés de fazer isso, eu vou usar a função init() da classe Phaser.Scene para passar informações sobre o herói e sobre qual mapa carregar na GameScene.

init(data) {
    this.initData = data;
}

create() {
    const { heroStatus, mapKey } = this.initData;

    const map = this.make.tilemap({ key: mapKey });
    map.addTilesetImage('tileset', 'tileset');
    map.layers.forEach((layer, index) => {
        map.createLayer(index, 'tileset', 0, 0);
    });

    const {
        position: initialPosition,
        health: heroHealth,
        coin: heroCoin,
    } = heroStatus;

    const heroSprite = this.physics.add.sprite(0, 0, 'hero');
    this.updateHeroHealthUi(heroHealth);
    this.updateHeroCoinUi(heroCoin);

    const gridEngineConfig = {
        characters: [{
            id: 'hero',
            sprite: heroSprite,
            startPosition: initialPosition,
        }],
    };
    this.gridEngine.create(map, gridEngineConfig);
}

Agora eu posso iniciar a minha GameScene com o código this.scene.start('GameScene', { heroStatus: { ...heroStatus }, mapKey: 'main-map' });, e agora eu posso começar a trabalhar no código para teleportar o herói entre mapas, e mais uma vez vou usar a layer de objetos do Tiled com uma propriedade personalizada com o nome teleportTo com o valor mapKey:positionX:positionY.

Mas e para recomeçar a mesma game scene já está rodando? Para isso existe a função this.scene.restart(). Então eu vou criar um custom collider para colidir com o herói e fazer o teleporte, adicionar um fade-out e, passar todos os dados do herói e do novo mapa para this.scene.restart().

this.itemsSprites = this.add.group();
const dataLayer = map.getObjectLayer('elements');
dataLayer.objects.forEach((data) => {
    const { properties, x, y } = data;

    properties.forEach((property) => {
        const { name, type, value } = property;

        switch (name) {
            case 'teleportTo': {
                const [mapKey, teleportToX, teleportToY] = value.split(':');
                const customCollider = new GameObjects.Rectangle(this, x, y, 16, 16).setOrigin(0, 1);
                const SCENE_FADE_TIME = 300;

                const overlapCollider = this.physics.add.overlap(this.heroSprite, customCollider, () => {
                    this.physics.world.removeCollider(overlapCollider);
                    const facingDirection = this.gridEngine.getFacingDirection('hero');
                    camera.fadeOut(SCENE_FADE_TIME);

                    this.time.delayedCall(SCENE_FADE_TIME, () => {
                        this.scene.restart({
                            heroStatus: {
                                position: { x: teleportToX, y: teleportToY },
                                health: this.heroSprite.health,
                                coin: this.heroSprite.coin,
                            },
                            mapKey: teleportToMapKey,
                        });
                    });
                });

                break;
            }

            default: {
                break;
            }
        }
    });
});

Agradecimentos

Este jogo não seria possível sem a ajuda de algumas pessoas incríveis e os seus trabalhos, então aqui está a minha lista de agradecimentos.

Conclusão

Foi muito divertido criar este pequeno projeto. Na verdade, fiz este jogo como uma nova maneira de acessar meu blog, mas com todo esse código boilerplate pronto, posso acabar fazendo um novo jogo com ele, vamos ver 👀.

Eu acabei escrevendo mais código no jogo “real” do que o que mostrei aqui nesse post, então confira o repositório GitHub para este projeto.

Até a próxima, galera!

Tags:


Publicar um comentário

Comentários

Nenhum comentário.