- Blog ➔
- Programação ↴
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 leituraSim, 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?
Jogue aqui!
Você também pode acessar o código-fonte no meu repositório do GitHub para este projeto.
Ok, 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);
}
}
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.
É importante criar seu mapa com várias layers, para que algumas partes possam estar abaixo e algumas partes acima do herói.
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 👀.
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.
- photonstorm, por ter criado o Phaser.io.
- Annoraaq, por ter criado o plugin grid-engine.
- ArMM1998, por ter criado os sprites dos personagens e os tilesets.
- PixElthen, por ter criado os sprites do slime.
- pixelartm, por ter criado o sprite do chapéu do pirata.
- jkjkke, por ter criado a imagem de background da tela de Game Over.
- KnoblePersona, por ter criado a imagem de background do menu inicial.
- Min, por ter criado o sprite do livro aberto.
Tags:
Posts relacionados
Publicar um comentário
Comentários
Nenhum comentário.