coding

Eu criei um jogo para o acessar o conteúdo do meu blog com Phaser e React

Aprenda a criar um jogo de RPG de cima para baixo usando Phaser e React, incluindo integração com Gatsby e criação do mapa do jogo com Tiled

Escrito em 12 de outubro de 2021 - 🕒 9 min. de leitura

Sim, isso mesmo, agora existe uma versão em jogo deste blog. Cansado de clicar em páginas chatas e ler coisas? Que tal mergulhar em uma jornada semelhante a um RPG top-down, encontrar os posts desse blog e lê-los dentro jogo?

pablo.gg - O jogo

Jogue aqui!

Você também pode acessar o código-fonte no meu repositório do GitHub para este projeto.

Ok, mas porquê?

Mas porquê?

A idéia nasceu quando eu adicionei o Konami Code no site, que faz aparecer o código-fonte da Matrix, que apesar de ser maneirinho, eu fiquei pensando que seria mais legal fazer o Konami Code abrir um jogo ou algo do tipo, e como eu já estou com 2 anos de experiência em Phaser, resolvi fazer um jogo bobo só como um MVP.

Na mesma época, descobri sobre o plugin grid-engine para o Phaser que torna muito mais fácil criar um jogo estilo top-down, e por causa disso eu decidi fazer um jogo com ele, e também porque a minha primeira experiência com “programação” foi em 2002, quando eu estava fazendo jogos com o RPG Maker.

Daí em diante o projeto começou a ficar um pouco mais ambicioso e eu pensei “e se eu mostrasse os posts do blog dentro do jogo?”, todos os dados do blog já estão disponíveis no React via GraphQL, então não deve ser tão difícil.

Usando React como UI para o 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;

E isso geralmente funciona bem, mas 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 gerenciamento de estado, como o Flux, mas como este é apenas um projeto muito pequeno, despachar eventos do JavaScript funcionará por agora. Veja no exemplo abaixo como fazer isso.

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

Integrando Phaser com Gatsby

Gatsby é um gerador de sites estáticos que usa React para pré-renderizar o HTML via node, mas o Phaser é um pacote apenas para cliente, e faz sentido, afinal por que você precisaria do Phaser no backend? Então, sempre que Gatsby estava pré-renderizando a minha página do jogo eu recebia erros de SSR porque Phaser estava tentando acessar algumas APIs de JavaScript que só funcionam no client-side.

Para resolver isso, eu usei o React hook useEffect para importar dinamicamente todos os meus módulos relacionados ao Phaser, já que o useEffect só é executado no client-side.

function GameComponent() {
    // to use async/await inside a useEffect we need to create an async function and call it
    useEffect(() => {
        async function initPhaser() {
            // Need to initialize Phaser here otherwise Gatsby will try to SSR it
            const Phaser = await import('phaser');
            const { default: GameScene } = await import('../game/scenes/GameScene');
            const { default: GridEngine } = await import('grid-engine');

            const game = new Phaser.Game({
                ...configs,
                parent: 'game-content',
                scene: [GameScene],
            });
        }

        initPhaser();
    }, []);

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

Criando o mapa

Para criar o mapa eu vou novamente usar o Tiled, um FOSS onde 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.

Primeiro vou criar o tileset no Tiled e definir uma propriedade chamada ge_collide com o valor true em todos os tiles que eu quero que tenha uma colisão com o herói.

Propriedade de colisão no Tiled
Propriedade de colisão no Tiled

É importante criar seu mapa com várias layers, para que algumas partes possam estar abaixo e algumas partes acima do herói.

Mapa com layers

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

Usando o plugin grid-engine para o Phaser

Como eu mencionei no começo do post, criar um jogo top-down estilo RPG Maker usando o plugin grid-engine é mole, basta configurar o seu jogo para usar o arcade physics e adicionar o grid-engine como plugin.

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

Como mencionei antes, para fazer as colisões funcionarem automaticamente com o plugin grid-engine e 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 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,
    });
}

Eu entro em mais detalhes de como as mecânicas do jogo foram feitas nesse post.

Pegando os dados dos posts com Gatsby

O Gatsby disponibiliza todos os seus dados estáticos através do GraphQL, no meu blog eu uso o plugin de markdown para os meus posts, então eu acesso meus dados da seguinte forma:

import React from 'react';
import { graphql } from 'gatsby';

function GamePage({ data }) {
    const allPosts = data.allMarkdownRemark.edges;
    
    return (
        <div>
            {allPosts.map((post, index) => {
                return (
                    <div>
                        <h1>{post.node.frontmatter.title}</h1>
                        <section
                            key={index}
                            dangerouslySetInnerHTML={{ __html: post.node.html }}
                        />
                    </div>
                );
            })}
        </div>
    );
}

export default GamePage;

export const pageQuery = graphql`
    query GamePage() {
        allMarkdownRemark(
            sort: { fields: [frontmatter___date], order: DESC }
        ) {
            edges {
                node {
                    html
                    frontmatter {
                        date
                        title
                        category
                    }
                }
            }
        }
    }
`;

Após adicionar a query do GraphQL, todos os dados dos meus posts estão disponíveis por meio do props data.allMarkdownRemark.edges.

Agora, no jogo, posso enviar um evento JavaScript pedindo ao React para mostrar todos os meus posts em uma lista ou algo assim, e dai quando um post for escolhido, mostrá-lo em um modal do Material UI.

Colocando tudo junto

Abaixo é como a versão final do meu component GamePage ficou:

import React from 'react';
import { graphql } from 'gatsby';

function GamePage({ data }) {
    const allPosts = data.allMarkdownRemark.edges;
    const [messages, setMessage] = useState('');
    const [showDialogBox, setShowDialogBox] = useState(false);
    const [showBlogPost, setShowBlogPost] = useState(false);
    const [showBlogPostList, setShowBlogPostList] = useState(false);
    const [post, setPost] = useState({});

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

        const showPostListEventListener = ({ detail }) => {
            setShowBlogPostList(true);
        };
        window.addEventListener('start-dialog', showPostListEventListener);

        const showPostEventListener = ({ detail }) => {
            setPost(detail.post);
            setShowBlogPost(true);
        };
        window.addEventListener('start-dialog', showPostEventListener);

        return () => {
            window.removeEventListener('start-dialog', dialogBoxEventListener);
            window.removeEventListener('show-post-list', showPostListEventListener);
            window.removeEventListener('show-post', showPostEventListener);
        };
    });

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

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

    useEffect(() => {
        async function initPhaser() {
            const Phaser = await import('phaser');
            const { default: GameScene } = await import('../game/scenes/GameScene');
            const { default: GridEngine } = await import('grid-engine');

            const game = new Phaser.Game({
                ...configs,
                parent: 'game-content',
                scene: [GameScene],
            });
        }

        initPhaser();
    }, []);

    return (
        <>
            {showDialogBox && (
                <DialogBox
                    message={message}
                    onDone={handleMessageIsDone}
                />
            )}
            {showBlogPost && (
                <BlogPost
                    post={post}
                />
            )}
            {showBlogPostList && (
                <BlogPostList
                    posts={allPosts}
                />
            )}
            <div id="game-content" />
        </>
    );
}

export default GamePage;

export const pageQuery = graphql`
    query GamePage() {
        allMarkdownRemark(
            sort: { fields: [frontmatter___date], order: DESC }
        ) {
            edges {
                node {
                    html
                    frontmatter {
                        date
                        title
                        category
                    }
                }
            }
        }
    }
`;

Se você quiser saber mais detalhes sobre o que acontece dentro da GameScene, leia esse post sobre o assunto.

Conclusão

Eu me diverti muito criando esse jogo para o meu blog, e, além disso, aprendi muitas coisas novas no Phaser e React. Quem sabe eu não uso parte desse código para fazer um jogo de verdade 👀.

pablo gg the game 1

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.

Tags:


Publicar um comentário

Comentários

Nenhum comentário.