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 - uma ferramenta poderosa para criar gráficos 3D baseados em 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 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.
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 é . 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.
Primeiro de tudo, precisaremos de um modelo de projeto React . Então, vamos começar instalando-o.
npm create vite@latest
npm install three @react-three/fiber @react-three/drei @react three/rapier zustand @tweenjs/tween.js
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.
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.
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 .
<mesh position={[0, 3, -5]}> <boxGeometry /> </mesh>
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 .
Vamos voltar ao componente Ground e adicionar um componente RigidBody como um wrapper sobre a superfície do piso.
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.
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.
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.
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.
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".
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.
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 dos desenvolvedores @react-two/fiber .
Ir para o conversor exigirá que você carregue o arquivo arma.glb convertido.
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 .
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 .
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.
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.
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.
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 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.
Para fazer isso, vamos adicionar alguns novos estados via useState .