No desenvolvimento web moderno, as fronteiras entre aplicativos clássicos e aplicativos web estão se confundindo a cada dia. Hoje podemos criar não apenas sites interativos, mas também jogos completos direto no navegador. Uma das ferramentas que torna isso possível é a biblioteca React Three Fiber - uma ferramenta poderosa para criar gráficos 3D baseados em Three.js usando a tecnologia React .
React Three Fiber é um wrapper sobre Three.js que usa a estrutura e os princípios do React para criar gráficos 3D na web. Essa pilha permite que os desenvolvedores combinem o poder do Three.js com a conveniência e flexibilidade do React , tornando o processo de criação de um aplicativo mais intuitivo e organizado.
No cerne do React Three Fiber está a ideia de que tudo o que você cria em uma cena é um componente do React . Isso permite que os desenvolvedores apliquem padrões e metodologias familiares.
Uma das principais vantagens do React Three Fiber é a facilidade de integração com o ecossistema React . Quaisquer outras ferramentas React ainda podem ser facilmente integradas ao usar esta biblioteca.
O Web-GameDev passou por grandes mudanças nos últimos anos, evoluindo de simples jogos Flash 2D para projetos 3D complexos comparáveis a aplicativos de desktop. Este crescimento em popularidade e capacidades torna o Web-GameDev uma área que não pode ser ignorada.
Uma das principais vantagens dos jogos na web é a sua acessibilidade. Os jogadores não precisam baixar e instalar nenhum software adicional – basta clicar no link em seu navegador. Isto simplifica a distribuição e promoção de jogos, disponibilizando-os para um amplo público em todo o mundo.
Por fim, o desenvolvimento de jogos para web pode ser uma ótima maneira para os desenvolvedores experimentarem o desenvolvimento de jogos usando tecnologias familiares. Graças às ferramentas e bibliotecas disponíveis, mesmo sem experiência em gráficos 3D, é possível criar projetos interessantes e de alta qualidade!
Os navegadores modernos percorreram um longo caminho, evoluindo de ferramentas de navegação bastante simples para plataformas poderosas para executar aplicativos e jogos complexos. Os principais navegadores como Chrome , Firefox , Edge e outros são constantemente otimizados e desenvolvidos para garantir alto desempenho, tornando-os uma plataforma ideal para o desenvolvimento de aplicações complexas.
Uma das principais ferramentas que impulsionou o desenvolvimento de jogos baseados em navegador é o WebGL . Esse padrão permitiu que os desenvolvedores usassem aceleração gráfica de hardware, o que melhorou significativamente o desempenho dos jogos 3D. Juntamente com outras webAPIs, o WebGL abre novas possibilidades para a criação de aplicações web impressionantes diretamente no navegador.
No entanto, ao desenvolver jogos para o navegador, é crucial considerar vários aspectos de desempenho: otimização de recursos, gestão de memória e adaptação para diferentes dispositivos são pontos-chave que podem afetar o sucesso de um projeto.
No entanto, palavras e teoria são uma coisa, mas a experiência prática é outra bem diferente. Para realmente compreender e apreciar todo o potencial do desenvolvimento de jogos web, a melhor maneira é mergulhar no processo de desenvolvimento. Portanto, como exemplo de desenvolvimento bem-sucedido de jogos para web, criaremos nosso próprio jogo. Este processo nos permitirá aprender os principais aspectos do desenvolvimento, enfrentar problemas reais e encontrar soluções para eles, e ver o quão poderosa e flexível uma plataforma de desenvolvimento de jogos web pode ser.
Em uma série de artigos, veremos como criar um jogo de tiro em primeira pessoa usando os recursos desta biblioteca e mergulhar no emocionante mundo do web-gamedev!
Repositório no GitHub
Agora vamos começar!
Primeiro de tudo, precisaremos de um modelo de projeto React . Então, vamos começar instalando-o.
npm create vite@latest
Instale pacotes npm adicionais.
npm install three @react-three/fiber @react-three/drei @react three/rapier zustand @tweenjs/tween.js
Em seguida, exclua tudo o que for desnecessário do nosso projeto.
No arquivo main.jsx , adicione um elemento div que será exibido na página como escopo. Insira um componente Canvas e defina o campo de visão da câmera. Dentro do componente Canvas coloque o componente App .
Vamos adicionar estilos a index.css para esticar os elementos da UI até a altura total da tela e exibir o escopo como um círculo no centro da tela.
No componente App adicionamos um componente Sky , que será exibido como plano de fundo em nossa cena de jogo na forma de um céu.
Vamos criar um componente Ground e colocá-lo no componente App .
Em Ground , crie um elemento de superfície plana. No eixo Y mova-o para baixo para que este plano fique no campo de visão da câmera. E também vire o plano no eixo X para torná-lo horizontal.
Embora tenhamos especificado cinza como cor do material, o plano parece completamente preto.
Por padrão, não há iluminação na cena, então vamos adicionar uma fonte de luz ambientLight , que ilumina o objeto por todos os lados e não possui feixe direcionado. Como parâmetro defina a intensidade do brilho.
Para que a superfície do piso não pareça homogênea, adicionaremos textura. Faça um padrão na superfície do piso na forma de células que se repetem ao longo de toda a superfície.
Na pasta de ativos adicione uma imagem PNG com textura.
Para carregar uma textura na cena, vamos usar o gancho useTexture do pacote @react-two/drei . E como parâmetro para o gancho passaremos a imagem da textura importada para o arquivo. Defina a repetição da imagem nos eixos horizontais.
Usando o componente PointerLockControls do pacote @react-two/drei , fixe o cursor na tela para que ele não se mova quando você move o mouse, mas mude a posição da câmera na cena.
Vamos fazer uma pequena edição no componente Ground .
Para maior clareza, vamos adicionar um cubo simples à cena.
<mesh position={[0, 3, -5]}> <boxGeometry /> </mesh>
Neste momento ele está apenas pendurado no espaço.
Use o componente Physics do pacote @react-two/rapier para adicionar "física" à cena. Como parâmetro, configure o campo gravitacional, onde definimos as forças gravitacionais ao longo dos eixos.
<Physics gravity={[0, -20, 0]}> <Ground /> <mesh position={[0, 3, -5]}> <boxGeometry /> </mesh> </Physics>
Porém, nosso cubo está dentro do componente físico, mas nada acontece com ele. Para fazer o cubo se comportar como um objeto físico real, precisamos envolvê-lo no componente RigidBody do pacote @react-two/rapier .
Depois disso, veremos imediatamente que cada vez que a página é recarregada, o cubo cai sob a influência da gravidade.
Mas agora há outra tarefa - é preciso fazer do chão um objeto com o qual o cubo possa interagir e além do qual não caia.
Vamos voltar ao componente Ground e adicionar um componente RigidBody como um wrapper sobre a superfície do piso.
Agora, ao cair, o cubo permanece no chão como um objeto físico real.
Vamos criar um componente Player que controlará o personagem em cena.
O personagem é o mesmo objeto físico que o cubo adicionado, portanto ele deve interagir com a superfície do chão e também com o cubo na cena. É por isso que adicionamos o componente RigidBody . E vamos fazer o personagem em forma de cápsula.
Coloque o componente Player dentro do componente Física.
Agora nosso personagem apareceu em cena.
O personagem será controlado através das teclas WASD , e saltará através da barra de espaço .
Com nosso próprio gancho de reação, implementamos a lógica de movimentação do personagem.
Vamos criar um arquivo hooks.js e adicionar uma nova função usePersonControls nele.
Vamos definir um objeto no formato {"keycode": "ação a ser executada"}. Em seguida, adicione manipuladores de eventos para pressionar e soltar teclas do teclado. Quando os manipuladores forem acionados, determinaremos as ações atuais que estão sendo executadas e atualizaremos seu estado ativo. Como resultado final, o gancho retornará um objeto no formato {"action in progress": "status"}.
Após implementar o gancho usePersonControls , ele deve ser usado ao controlar o personagem. No componente Player adicionaremos rastreamento do estado de movimento e atualizaremos o vetor da direção do movimento do personagem.
Também definiremos variáveis que armazenarão os estados das direções de movimento.
Para atualizar a posição do personagem, vamos usarFrame fornecido pelo pacote @react-two/fiber . Este gancho funciona de forma semelhante a requestAnimationFrame e executa o corpo da função cerca de 60 vezes por segundo.
Explicação do código:
1. const playerRef = useRef(); Crie um link para o objeto do jogador. Este link permitirá a interação direta com o objeto do jogador na cena.
2. const {avançar, retroceder, esquerda, direita, pular} = usePersonControls(); Quando um gancho é usado, é retornado um objeto com valores booleanos indicando quais botões de controle estão pressionados no momento pelo jogador.
3. useFrame((estado) => {... }); O gancho é chamado em cada quadro da animação. Dentro deste gancho, a posição e a velocidade linear do jogador são atualizadas.
4. if (!playerRef.current) retornar; Verifica a presença de um objeto de jogador. Se não houver nenhum objeto player, a função interromperá a execução para evitar erros.
5. velocidade const = playerRef.current.linvel(); Obtenha a velocidade linear atual do jogador.
6. frontVector.set(0, 0, para trás - para frente); Defina o vetor de movimento para frente/trás com base nos botões pressionados.
7. sideVector.set(esquerda - direita, 0, 0); Defina o vetor de movimento esquerda/direita.
8. direção.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED); Calcule o vetor final de movimento do jogador subtraindo os vetores de movimento, normalizando o resultado (de forma que o comprimento do vetor seja 1) e multiplicando pela constante de velocidade de movimento.
9.playerRef.current.wakeUp(); "Acorda" o objeto do jogador para garantir que ele reaja às mudanças. Se você não usar este método, depois de algum tempo o objeto “adormecerá” e não reagirá às mudanças de posição.
10. playerRef.current.setLinvel({ x: direção.x, y: velocidade.y, z: direção.z }); Defina a nova velocidade linear do jogador com base na direção calculada do movimento e mantenha a velocidade vertical atual (para não afetar saltos ou quedas).
Como resultado, ao pressionar as teclas WASD , o personagem começou a se movimentar pelo cenário. Ele também pode interagir com o cubo, pois ambos são objetos físicos.
Para implementar o salto, vamos usar a funcionalidade dos pacotes @dimforge/rapier3d-compat e @react-two/rapier . Neste exemplo, vamos verificar se o personagem está no chão e se a tecla de pular foi pressionada. Neste caso, definimos a direção do personagem e a força de aceleração no eixo Y.
Para o Player adicionaremos massa e bloquearemos a rotação em todos os eixos, para que ele não caia em direções diferentes ao colidir com outros objetos no cenário.
Explicação do código:
- const mundo = rapier.mundo; Obtendo acesso ao cenário do motor de física Rapier . Ele contém todos os objetos físicos e gerencia sua interação.
- const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 })); É aqui que ocorre o "raycasting" (raycasting). É criado um raio que começa na posição atual do jogador e aponta para baixo no eixo y. Este raio é “projetado” na cena para determinar se ele intercepta algum objeto na cena.
- const aterrado = ray && ray.collider && Math.abs(ray.toi) <= 1,5; A condição é verificada se o jogador estiver no chão:
- raio - se o raio foi criado;
- ray.collider - se o raio colidiu com algum objeto na cena;
- Math.abs(ray.toi) - o "tempo de exposição" do raio. Se este valor for inferior ou igual ao valor indicado, pode indicar que o jogador está suficientemente próximo da superfície para ser considerado “no chão”.
Você também precisa modificar o componente Ground para que o algoritmo raytraced para determinar o status de "aterrissagem" funcione corretamente, adicionando um objeto físico que irá interagir com outros objetos na cena.
Vamos levantar um pouco mais a câmera para uma melhor visualização da cena.
Código da seção
Para mover a câmera, obteremos a posição atual do player e alteraremos a posição da câmera toda vez que o quadro for atualizado. E para que o personagem se mova exatamente ao longo da trajetória para onde a câmera está direcionada, precisamos adicionar applyEuler .
Explicação do código:
O método applyEuler aplica rotação a um vetor com base em ângulos de Euler especificados. Neste caso, a rotação da câmera é aplicada ao vetor de direção . Isto é usado para combinar o movimento relativo à orientação da câmera, de modo que o jogador se mova na direção em que a câmera é girada.
Vamos ajustar levemente o tamanho do Player e torná-lo mais alto em relação ao cubo, aumentando o tamanho do CapsuleCollider e corrigindo a lógica de "salto".
Código da seção
Para que a cena não pareça completamente vazia, vamos adicionar a geração de cubos. No arquivo json, liste as coordenadas de cada um dos cubos e exiba-as na cena. Para fazer isso, crie um arquivo cubes.json , no qual listaremos um array de coordenadas.
[ [0, 0, -7], [2, 0, -7], [4, 0, -7], [6, 0, -7], [8, 0, -7], [10, 0, -7] ]
No arquivo Cube.jsx , crie um componente Cubes , que gerará cubos em um loop. E o componente Cube será um objeto gerado diretamente.
import {RigidBody} from "@react-three/rapier"; import cubes from "./cubes.json"; export const Cubes = () => { return cubes.map((coords, index) => <Cube key={index} position={coords} />); } const Cube = (props) => { return ( <RigidBody {...props}> <mesh castShadow receiveShadow> <meshStandardMaterial color="white" /> <boxGeometry /> </mesh> </RigidBody> ); }
Vamos adicionar o componente Cubes criado ao componente App excluindo o cubo único anterior.
Agora vamos adicionar um modelo 3D à cena. Vamos adicionar um modelo de arma para o personagem. Vamos começar procurando um modelo 3D. Por exemplo, vamos pegar este .
Baixe o modelo no formato GLTF e descompacte o arquivo na raiz do projeto.
Para obter o formato que precisamos para importar o modelo para a cena, precisaremos instalar o pacote complementar gltf-pipeline .
npm i -D gltf-pipeline
Usando o pacote gltf-pipeline , reconverta o modelo do formato GLTF para o formato GLB , pois neste formato todos os dados do modelo são colocados em um arquivo. Como diretório de saída para o arquivo gerado, especificamos a pasta pública .
gltf-pipeline -i weapon/scene.gltf -o public/weapon.glb
Então precisamos gerar um componente react que conterá a marcação deste modelo para adicioná-lo à cena. Vamos usar o recurso oficial dos desenvolvedores @react-two/fiber .
Ir para o conversor exigirá que você carregue o arquivo arma.glb convertido.
Usando arrastar e soltar ou pesquisa no Explorer, encontre este arquivo e baixe-o.
No conversor veremos o componente react gerado, cujo código transferiremos para o nosso projeto em um novo arquivo WeaponModel.jsx , alterando o nome do componente para o mesmo nome do arquivo.
Agora vamos importar o modelo criado para a cena. No arquivo App.jsx , adicione o componente WeaponModel .
Neste ponto da nossa cena, nenhum dos objetos está projetando sombras.
Para habilitar sombras na cena você precisa adicionar o atributo shadows ao componente Canvas .
Em seguida, precisamos adicionar uma nova fonte de luz. Apesar de já termos ambientLight na cena, ele não consegue criar sombras para objetos, pois não possui feixe de luz direcional. Então, vamos adicionar uma nova fonte de luz chamada direcionalLight e configurá-la. O atributo para ativar o modo de sombra " cast " é castShadow . É a adição deste parâmetro que indica que este objeto pode projetar sombra sobre outros objetos.
Depois disso, vamos adicionar outro atributo recebeShadow ao componente Ground , o que significa que o componente na cena pode receber e exibir sombras sobre si mesmo.
Atributos semelhantes devem ser adicionados a outros objetos na cena: cubos e jogador. Para os cubos adicionaremos castShadow e ReceiveShadow , pois ambos podem lançar e receber sombras, e para o jogador adicionaremos apenas castShadow .
Vamos adicionar castShadow para Player .
Adicione castShadow e recebaShadow para Cube .
Se você olhar atentamente agora, descobrirá que a área da superfície sobre a qual a sombra é projetada é bem pequena. E ao ultrapassar esta área, a sombra é simplesmente cortada.
A razão para isso é que, por padrão, a câmera captura apenas uma pequena área das sombras exibidas em direcionalLight . Podemos fazer isso para o componente direcionalLight adicionando atributos adicionais shadow-camera-(top, bottom, left, right) para expandir esta área de visibilidade. Depois de adicionar esses atributos, a sombra ficará ligeiramente desfocada. Para melhorar a qualidade, adicionaremos o atributo shadow-mapSize .
Agora vamos adicionar a exibição de armas em primeira pessoa. Crie um novo componente Arma , que conterá a lógica de comportamento da arma e o próprio modelo 3D.
import {WeaponModel} from "./WeaponModel.jsx"; export const Weapon = (props) => { return ( <group {...props}> <WeaponModel /> </group> ); }
Vamos colocar este componente no mesmo nível do RigidBody do personagem e no gancho useFrame definiremos a posição e o ângulo de rotação com base na posição dos valores da câmera.
Para tornar a marcha do personagem mais natural, adicionaremos um leve movimento da arma durante o movimento. Para criar a animação usaremos a biblioteca tween.js instalada.
O componente Arma será agrupado em uma tag de grupo para que você possa adicionar uma referência a ele por meio do gancho useRef .
Vamos adicionar useState para salvar a animação.
Vamos criar uma função para inicializar a animação.
Explicação do código:
- const twSwayingAnimation = new TWEEN.Tween(currentPosition) ... Criando uma animação de um objeto "balançando" de sua posição atual para uma nova posição.
- const twSwayingBackAnimation = new TWEEN.Tween(currentPosition) ... Criando uma animação do objeto retornando à sua posição inicial após a conclusão da primeira animação.
- twSwayingAnimation.chain(twSwayingBackAnimation); Conectar duas animações para que, quando a primeira animação for concluída, a segunda animação seja iniciada automaticamente.
Em useEffect chamamos a função de inicialização da animação.
Agora é necessário determinar o momento em que ocorre o movimento. Isso pode ser feito determinando o vetor atual da direção do personagem.
Se ocorrer movimento do personagem, atualizaremos a animação e a executaremos novamente quando terminar.
Explicação do código:
- const isMoving = direção.comprimento() > 0; Aqui o estado de movimento do objeto é verificado. Se o vetor de direção tiver comprimento maior que 0, significa que o objeto tem uma direção de movimento.
- if (isMoving && isSwayingAnimationFinished) { ... } Este estado é executado se o objeto estiver se movendo e a animação de "balanço" tiver terminado.
No componente App , vamos adicionar um useFrame onde atualizaremos a animação de interpolação.
TWEEN.update() atualiza todas as animações ativas na biblioteca TWEEN.js . Este método é chamado em cada quadro de animação para garantir que todas as animações sejam executadas sem problemas.
Código da seção:
Precisamos definir o momento em que um tiro é disparado – ou seja, quando o botão do mouse é pressionado. Vamos adicionar useState para armazenar esse estado, useRef para armazenar uma referência ao objeto arma e dois manipuladores de eventos para pressionar e soltar o botão do mouse.
Vamos implementar uma animação de recuo ao clicar com o botão do mouse. Usaremos a biblioteca tween.js para essa finalidade.
Vamos definir constantes para força de recuo e duração da animação.
Tal como acontece com a animação de movimento da arma, adicionamos dois estados useState para a animação de recuo e retorno à posição inicial e um estado com o status final da animação.
Vamos criar funções para obter um vetor aleatório de animação de recuo - generateRecoilOffset e generateNewPositionOfRecoil .
Crie uma função para inicializar a animação de recuo. Também adicionaremos useEffect , no qual especificaremos o estado "shot" como uma dependência, para que a cada disparo a animação seja inicializada novamente e novas coordenadas finais sejam geradas.
E em useFrame , vamos adicionar uma verificação para "segurar" a tecla do mouse para disparar, para que a animação de disparo não pare até que a tecla seja liberada.
Realize a animação de “inatividade” para o personagem, para que não haja sensação de jogo “travando”.
Para fazer isso, vamos adicionar alguns novos estados via useState .
Vamos corrigir a inicialização da animação "wiggle" para usar valores do estado. A ideia é que diferentes estados: caminhando ou parando, utilizem valores diferentes para a animação e cada vez que a animação seja inicializada primeiro.
Nesta parte implementamos a geração de cena e movimentação de personagens. Também adicionamos um modelo de arma, animação de recuo ao disparar e em modo inativo. Na próxima parte continuaremos a refinar nosso jogo, adicionando novas funcionalidades.
Também publicado aqui .