Привет, уважаемые участники Хабр! 

Введение

В современной веб-разработке границы между классическими и веб-приложениями стираются с каждым днём. Сегодня мы можем создавать не только интерактивные сайты, но и полноценные игры прямо в браузере. Одним из инструментов, который делает это возможным, является библиотека React Three Fiber - мощное средство для создания 3D-графики на основе Three.js с использованием технологии React.

О стеке React Three Fiber

React Three Fiber - это обёртка над Three.js, которая использует структуру и принципы React для создания 3D-графики в вебе. Этот стек позволяет разработчикам объединить мощь Three.js с удобством и гибкостью React, делая процесс создания приложения более интуитивным и организованным.

В основе React Three Fiber лежит идея, что всё, что вы создаёте на сцене, является компонентами React. Это позволяет разработчикам применять знакомые паттерны и методологии.

Одним из главных преимуществ React Three Fiber является его простота интеграции с экосистемой React. При использовании этой библиотеки по-прежнему можно легко интегрировать любые другие инструменты React.

Актуальность Web-GameDev

Web-GameDev за последние годы пережил серьёзные изменения, превратившись из простых 2D-игр на Flash в сложные 3D-проекты, сопоставимые с настольными приложениями. Этот рост популярности и возможностей делает Web-GameDev областью, которую нельзя игнорировать.

Одним из главных преимуществ веб-игр является их доступность. Игрокам не требуется скачивать и устанавливать дополнительные программы - достаточно просто перейти по ссылке в браузере. Это упрощает распространение и продвижение игр, делая их доступными для широкой аудитории по всему миру.

Наконец, разработка веб-игр может стать отличным способом для разработчиков попробовать свои силы в gamedev, используя знакомые технологии. Благодаря доступным инструментам и библиотекам, даже без опыта в 3D-графике можно создать интересные и качественные проекты!

Производительность игр в современных браузерах

Современные браузеры прошли долгий путь развития, превратившись из достаточно простых инструментов для просмотра веб-страниц в мощные платформы для запуска сложных приложений и игр. Основные браузеры, такие как Chrome, Firefox, Edge и другие, постоянно оптимизируются и развиваются для обеспечения высокой производительности, что делает их идеальной платформой для разработки сложных приложений.

Одним из ключевых инструментов, который дал толчок развитию игр в браузере, является WebGL. Этот стандарт позволил разработчикам использовать аппаратное ускорение графики, что существенно повысило производительность 3D-игр. Совместно с другими веб-API, WebGL открывает новые возможности для создания впечатляющих веб-приложений прямо в браузере.

Тем не менее при разработке игр для браузера крайне важно учитывать различные аспекты производительности: оптимизация ресурсов, управление памятью и адаптация под разные устройства - всё это ключевые моменты, которые могут повлиять на успешность проекта.

На старт!

Однако слова и теория - это одно, а практический опыт - совершенно иное. Чтобы действительно понять и оценить весь потенциал Web-GameDev, лучший способ - погрузиться в процесс разработки. Поэтому в качества примера успешности разработки веб-игр мы создадим собственную игру. Этот процесс позволит нам освоить ключевые аспекты разработки, столкнуться с реальными проблемами и найти пути их решения, а также на практике увидеть, насколько мощной и гибкой может быть платформа веб-геймдева.

В серии статей мы рассмотрим, как создать шутер от первого лица, используя возможности данной библиотеки, и погрузимся в увлекательный мир веб-игр!

Финальное демо

Репозиторий на GitHub

А теперь, давайте начинать!

Настройка проекта и установка пакетов

В первую очередь нам понадобится шаблон проекта на React. Поэтому начнём с его установки.

npm create vite@latest
  • выбираем библиотеку React;

  • выбираем JavaScript.

Устанавливаем дополнительные npm-пакеты.

npm install three @react-three/fiber @react-three/drei @react-three/rapier zustand @tweenjs/tween.js

Затем удалим всё лишнее из нашего проекта.

Код раздела

Настройка отображения Canvas

В файл main.jsx добавим div-элемент, который будет отображаться на странице в качестве прицела. Вставим компонент Canvas и зададим поле зрения камеры. Внутрь компонента Canvas поместим компонент App.

main.jsx
main.jsx
main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import {Canvas} from "@react-three/fiber";

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <div id="container">
        <div className="aim"></div>
        <Canvas camera={{ fov: 45 }}>
            <App />
        </Canvas>
    </div>
  </React.StrictMode>,
)

Добавим стили, чтобы растянуть элементы интерфейса на всю высоту экрана и вывести прицела в виде круга по центру экрана.

index.css
index.css
index.css
* {
    margin: 0;
    padding: 0;
}

html, body, #root, #container {
    height: 100%;
}

.aim {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 10px;
    height: 10px;
    border-radius: 50%;
    transform: translate3d(-50%, -50%, 0);
    border: 2px solid white;
    z-index: 2;
}

В компонент App добавляем компонент Sky, который будет отображаться задним фоном в нашей игровой сцене в виде неба.

App.jsx
App.jsx
App.jsx
import {Sky} from "@react-three/drei";

export const App = () => {
  return (
    <>
      <Sky sunPosition={[100, 20, 100]} />
    </>
  )
}

export default App

Отображение неба в сцене
Отображение неба в сцене

Код раздела

Поверхность пола

Создадим компонент Ground и поместим его в компонент App.

App.jsx
App.jsx
App.jsx
import {Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";

export const App = () => {
    return (
        <>
            <Sky sunPosition={[100, 20, 100]}/>
            <Ground />
        </>
    )
}

export default App

В Ground создадим элемент плоской поверхности. По оси Y переместим вниз, чтобы эта плоскость попала в поле зрения камеры. А также перевернём плоскость по оси Х, чтобы она приняла горизонтальное положение.

Ground.jsx
Ground.jsx
Ground.jsx
export const Ground = () => {
    return (
        <mesh position={[0, -5, 0]} rotation-x={-Math.PI / 2}>
            <planeGeometry args={[500, 500]} />
            <meshStandardMaterial color="gray" />
        </mesh>
    );
}

Несмотря на то что в качестве цвета материала мы указали серый, плоскость отображается полностью чёрной.

Плоскость на сцене
Плоскость на сцене

Код раздела

Базовое освещение

По умолчанию в сцене отсутствует освещение, поэтому добавим источник света ambientLight, который освещает объект со всех сторон и не имеет направленного луча. В качестве параметра настроим силу свечения.

App.jsx
App.jsx
import {Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";

export const App = () => {
    return (
        <>
            <Sky sunPosition={[100, 20, 100]}/>
            <ambientLight intensity={1.5} />
            <Ground />
        </>
    )
}

export default App

Освещённая плоскость
Освещённая плоскость

Код раздела

Текстура для поверхности пола

Чтобы поверхность пола не выглядела однородно, добавим текстуру. Сделаем рисунок поверхности пола в виде ячеек, повторяющихся на всём протяжении поверхности.

В папку assets добавим изображение PNG с текстурой.

Текстура для плоскости
Текстура для плоскости

Для загрузки текстуры на сцену воспользуемся хуком useTexture из пакета @react-three/drei. А в качестве параметра для хука передадим импортированное в файл изображение текстуры. Зададим повторение изображения горизонтальным осям.

Ground.jsx
Ground.jsx
Ground.jsx
import * as THREE from "three";
import { useTexture } from "@react-three/drei";
import floorTexture from "./assets/floor.png";

export const Ground = () => {
    const texture = useTexture(floorTexture);
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping;

    return (
        <mesh position={[0, -5, 0]} rotation-x={-Math.PI / 2}>
            <planeGeometry args={[500, 500]} />
            <meshStandardMaterial color="gray" map={texture} map-repeat={[360, 360]} />
        </mesh>
    );
}

Текстура на плоскости
Текстура на плоскости

Код раздела

Движение камерой

При помощи компонента PointerLockControls из пакета @react-three/drei зафиксируем курсор на экране, чтобы он не перемещался при движении мышью, а менялось положение камеры на сцене.

App.jsx
App.jsx
App.jsx
import {PointerLockControls, Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";

export const App = () => {
    return (
        <>
            <PointerLockControls />
            <Sky sunPosition={[100, 20, 100]}/>
            <ambientLight intensity={1.5} />
            <Ground />
        </>
    )
}

export default App

Демонстрация движения камерой
Демонстрация движения камерой

Сделаем небольшую правку для компонента Ground.

Ground.jsx
Ground.jsx
Ground.jsx
import * as THREE from "three";
import { useTexture } from "@react-three/drei";
import floorTexture from "./assets/floor.png";

export const Ground = () => {
    const texture = useTexture(floorTexture);
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping;

    return (
        <mesh position={[0, -5, 0]} rotation-x={-Math.PI / 2}>
            <planeGeometry args={[500, 500]} />
            <meshStandardMaterial color="gray" map={texture} map-repeat={[100, 100]} />
        </mesh>
    );
}

Код раздела

Добавляем физику

Для наглядности добавим на сцену простой куб.

<mesh position={[0, 3, -5]}>
    <boxGeometry />
</mesh>
Куб на сцене
Куб на сцене

Сейчас он просто висит в пространстве.

При помощи компонента Physics из пакета @react-three/rapier добавим “физику” на сцену. В качестве параметра настроим поле gravity, в котором зададим силы притяжения по осям.

<Physics gravity={[0, -20, 0]}>
    <Ground />
    <mesh position={[0, 3, -5]}>
        <boxGeometry />
    </mesh>
</Physics>

Однако, наш куб находится внутри компонента с физикой, но с ним ничего не происходит. Для того чтобы куб вёл себя как реальный физический объект, необходимо обернуть его в компонент RigidBody из пакета @react-three/rapier.

App.jsx
App.jsx
App.jsx
import {PointerLockControls, Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";
import {Physics, RigidBody} from "@react-three/rapier";

export const App = () => {
    return (
        <>
            <PointerLockControls />
            <Sky sunPosition={[100, 20, 100]}/>
            <ambientLight intensity={1.5} />
            <Physics gravity={[0, -20, 0]}>
                <Ground />
                <RigidBody>
                    <mesh position={[0, 3, -5]}>
                        <boxGeometry />
                    </mesh>
                </RigidBody>
            </Physics>
        </>
    )
}

export default App

После этого сразу же увидим, что при каждой перезагрузке страницы куб под воздействием гравитации падает.

Падение куба
Падение куба

Но теперь появилась другая задача - необходимо сделать пол таким объектом, с которым может взаимодействовать куб, и за пределы которого он не провалится.

Код раздела

Пол как физический объект

Вернёмся к компоненту Ground и добавим компонент RigidBody в качестве обёртки над поверхностью пола.

Ground.jsx
Ground.jsx
Ground.jsx
import * as THREE from "three";
import { useTexture } from "@react-three/drei";
import floorTexture from "./assets/floor.png";
import {RigidBody} from "@react-three/rapier";

export const Ground = () => {
    const texture = useTexture(floorTexture);
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping;

    return (
        <RigidBody>
            <mesh position={[0, -5, 0]} rotation-x={-Math.PI / 2}>
                <planeGeometry args={[500, 500]} />
                <meshStandardMaterial color="gray" map={texture} map-repeat={[100, 100]} />
            </mesh>
        </RigidBody>
    );
}

Теперь при падении куб остаётся на полу как на реальном физическом объекте.

Падение куба на плоскость
Падение куба на плоскость

Код раздела

Подчинение персонажа законам физики

Создадим компонент Player, который будет контролировать персонажа на сцене.

Персонаж - это такой же физический объект, как и добавленный куб, поэтому он должен взаимодействовать с поверхностью пола, а также с кубом на сцене. Поэтому добавляем компонент RigidBody. А персонажа сделаем в форме капсулы.

Player.jsx
Player.jsx
Player.jsx
import {RigidBody} from "@react-three/rapier";

export const Player = () => {
    return (
        <>
            <RigidBody position={[0, 1, -2]}>
                <mesh>
                    <capsuleGeometry args={[0.5, 0.5]}/>
                </mesh>
            </RigidBody>
        </>
    );
}

Компонент Player поместим внутрь компонента Physics.

App.jsx
App.jsx
App.jsx
import {PointerLockControls, Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";
import {Physics, RigidBody} from "@react-three/rapier";
import {Player} from "./Player.jsx";

export const App = () => {
    return (
        <>
            <PointerLockControls />
            <Sky sunPosition={[100, 20, 100]}/>
            <ambientLight intensity={1.5} />
            <Physics gravity={[0, -20, 0]}>
                <Ground />
                <Player />
                <RigidBody>
                    <mesh position={[0, 3, -5]}>
                        <boxGeometry />
                    </mesh>
                </RigidBody>
            </Physics>
        </>
    )
}

export default App

Теперь на сцене появился наш персонаж.

Персонаж в форме капсулы
Персонаж в форме капсулы

Код раздела

Перемещение персонажа - создание хука

Управление персонажем будет осуществляться при помощи клавиш WASD, а прыжок при помощи кнопки Пробел.

С помощью собственного react-хука мы реализуем логику перемещения персонажа.

Создадим файл hooks.js и добавим туда новую функцию usePersonControls.

Определим объект в формате {“код клавиши”: “выполняемое действие”}. Далее добавим обработчики событий при нажатии и отпускании клавиш клавиатуры. При срабатывании обработчиков будем определять текущие выполняемые действия и обновлять их активное состояние. В конечном результате из хука вернём объект в формате {“выполняемое действие”: “статус”}.

hooks.js
hooks.js
hooks.js
import { useEffect, useState } from "react";

export const usePersonControls = () => {
    const keys = {
        KeyW: "forward",
        KeyS: "backward",
        KeyA: "left",
        KeyD: "right",
        Space: "jump"
    }

    const moveFieldByKey = (key) => keys[key];

    const [movement, setMovement] = useState({
        forward: false,
        backward: false,
        left: false,
        right: false,
        jump: false
    });

    const setMovementStatus = (code, status) => {
        setMovement((m) => ({...m, [code]: status}))
    }

    useEffect(() => {
        const handleKeyDown = (ev) => {
            setMovementStatus(moveFieldByKey(ev.code), true);
        }

        const handleKeyUp = (ev) => {
            setMovementStatus(moveFieldByKey(ev.code), false);
        }

        document.addEventListener('keydown', handleKeyDown);
        document.addEventListener('keyup', handleKeyUp);

        return () => {
            document.removeEventListener('keydown', handleKeyDown);
            document.removeEventListener('keyup', handleKeyUp);
        }
    }, []);

    return movement;
}

Код раздела

Перемещение персонажа - внедрение хука

После реализации хука usePersonControls его необходимо использовать при управлении персонажем. В компоненте Player добавим отслеживание состояния движения и будем обновлять вектор направления движения персонажа. 

Также определим переменные, которые будут хранить состояния направлений движения.

Player.jsx
Player.jsx

Для обновления положения персонажа воспользуемся useFrame, предоставляемым пакетом @react-three/fiber. Данный хук работает аналогично requestAnimationFrame и выполняет тело функции примерно 60 раз в секунду.

Player.jsx
Player.jsx

Пояснение по коду

  1. const playerRef = useRef();
    Создаём ссылку для объекта игрока. Эта ссылка позволит напрямую взаимодействовать с объектом игрока на сцене.

  2. const { forward, backward, left, right, jump } = usePersonControls();
    При использовании хука возвращается объект с булевыми значениями, указывающими, какие кнопки управления в данный момент нажаты игроком.

  3. useFrame((state) => { ... });
    Хук вызывается на каждом кадре анимации. Внутри этого хука происходит обновление позиции и линейной скорости игрока. 

  4. if (!playerRef.current) return;
    Проверка на наличие объекта игрока. Если объект игрока отсутствует, то, во избежание ошибок, функция прекратит выполнение.

  5. const velocity = playerRef.current.linvel();
    Получение текущей линейной скорости игрока.

  6. frontVector.set(0, 0, backward - forward);
    Установка вектора движения вперёд/назад на основе нажатых кнопок.

  7. sideVector.set(left - right, 0, 0);
    Установка вектора движения влево/вправо.

  8. direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED);
    Вычисление итогового вектора движения игрока путём вычитания векторов движения, нормализации результата (чтобы длина вектора была равна 1) и умножения на константу скорости движения.

  9. playerRef.current.wakeUp();
    "Пробуждение" объекта игрока, чтобы убедиться, что он реагирует на изменения. Если не использовать данный метод, то через некоторое время объект “заснёт” и не будет реагировать на изменение позиции.

  10. playerRef.current.setLinvel({ x: direction.x, y: velocity.y, z: direction.z });
    Установка новой линейной скорости игрока на основе вычисленного направления движения и сохранение текущей вертикальной скорости (чтобы не влиять на прыжки или падения).

Player.jsx
import * as THREE from "three";
import {RigidBody} from "@react-three/rapier";
import {useRef} from "react";
import {usePersonControls} from "./hooks.js";
import {useFrame} from "@react-three/fiber";

const MOVE_SPEED = 5;
const direction = new THREE.Vector3();
const frontVector = new THREE.Vector3();
const sideVector = new THREE.Vector3();

export const Player = () => {
    const playerRef = useRef();
    const { forward, backward, left, right, jump } = usePersonControls();

    useFrame((state) => {
        if (!playerRef.current) return;

        const velocity = playerRef.current.linvel();

        frontVector.set(0, 0, backward - forward);
        sideVector.set(left - right, 0, 0);
        direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED);

        playerRef.current.wakeUp();
        playerRef.current.setLinvel({ x: direction.x, y: velocity.y, z: direction.z });
    });

    return (
        <>
            <RigidBody position={[0, 1, -2]} ref={playerRef}>
                <mesh>
                    <capsuleGeometry args={[0.5, 0.5]}/>
                </mesh>
            </RigidBody>
        </>
    );
}

В результате при нажатии на клавиши WASD персонаж начал перемещаться по сцене. Также он может взаимодействовать с кубом, потому что они оба являются физическими объектами.

Перемещение персонажа
Перемещение персонажа

Код раздела

Перемещение персонажа - прыжок

Для того чтобы реализовать прыжок, воспользуемся функционалом из пакетов @dimforge/rapier3d-compat и @react-three/rapier. В данном примере проверим, что персонаж находится на земле и была нажата клавиша для прыжка. В таком случае мы задаём персонажу направление и силу ускорения по оси Y.

Ещё для Player добавим массу и заблокируем вращения по всем осям, чтобы он не заваливался в разные стороны при столкновении с другими объектами на сцене.

Player.jsx
Player.jsx

Пояснение по коду

  1. const world = rapier.world;
    Получение доступа к сцене физического движка Rapier. Он содержит все физические объекты и управляет их взаимодействием.

  2. const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 }));
    В этом месте происходит “лучевое прослеживание” (raycasting). Создаётся луч, который начинается в текущей позиции игрока и направлен вниз по оси Y. Этот луч “бросается” в сцену, чтобы определить, пересекается ли он с каким-либо объектом на сцене.

  3. const grounded = ray && ray.collider && Math.abs(ray.toi) <= 1.5;
    Проверяется условие, находится ли игрок на земле:

    • ray - был ли луч создан;

    • ray.collider - столкнулся ли луч с каким-либо объектом на сцене;

    • Math.abs(ray.toi) - “время воздействия” луча. Если это значение меньше или равно заданному, это может указывать на то, что игрок находится достаточно близко к поверхности, чтобы считаться “на земле”.

Player.jsx
import * as THREE from "three";
import * as RAPIER from "@dimforge/rapier3d-compat"
import {RigidBody, useRapier} from "@react-three/rapier";
import {useRef} from "react";
import {usePersonControls} from "./hooks.js";
import {useFrame} from "@react-three/fiber";

const MOVE_SPEED = 5;
const direction = new THREE.Vector3();
const frontVector = new THREE.Vector3();
const sideVector = new THREE.Vector3();

export const Player = () => {
    const playerRef = useRef();
    const { forward, backward, left, right, jump } = usePersonControls();

    const rapier = useRapier();

    useFrame((state) => {
        if (!playerRef.current) return;

        // moving player
        const velocity = playerRef.current.linvel();

        frontVector.set(0, 0, backward - forward);
        sideVector.set(left - right, 0, 0);
        direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED);

        playerRef.current.wakeUp();
        playerRef.current.setLinvel({ x: direction.x, y: velocity.y, z: direction.z });

        // jumping
        const world = rapier.world;
        const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 }));
        const grounded = ray && ray.collider && Math.abs(ray.toi) <= 1;

        if (jump && grounded) doJump();
    });

    const doJump = () => {
        playerRef.current.setLinvel({x: 0, y: 8, z: 0});
    }

    return (
        <>
            <RigidBody position={[0, 1, -2]} mass={1} ref={playerRef} lockRotations>
                <mesh>
                    <capsuleGeometry args={[0.5, 0.5]}/>
                </mesh>
            </RigidBody>
        </>
    );
}

Также необходимо изменить компонент Ground, чтобы корректно работал алгоритм с лучами для определения статуса “приземления”, добавив в него физический объект, который будет взаимодействовать с другими объектами на сцене.

Ground.jsx
Ground.jsx
Ground.jsx
import * as THREE from "three";
import { useTexture } from "@react-three/drei";
import floorTexture from "./assets/floor.png";
import {CuboidCollider, RigidBody} from "@react-three/rapier";

export const Ground = () => {
    const texture = useTexture(floorTexture);
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping;

    return (
        <RigidBody type="fixed" colliders={false}>
            <mesh position={[0, 0, 0]} rotation-x={-Math.PI / 2}>
                <planeGeometry args={[500, 500]} />
                <meshStandardMaterial color="gray" map={texture} map-repeat={[100, 100]} />
            </mesh>
            <CuboidCollider args={[500, 2, 500]} position={[0, -2, 0]}/>
        </RigidBody>
    );
}

Поднимем камеру немного выше для лучшей видимости сцены.

main.jsx
main.jsx
main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import {Canvas} from "@react-three/fiber";

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <div id="container">
        <div className="aim"></div>
        <Canvas camera={{ fov: 45, position: [0, 5, 0] }}>
            <App />
        </Canvas>
    </div>
  </React.StrictMode>,
)

Прыжки персонажа
Прыжки персонажа

Код раздела:

Перемещение камеры за персонажем

Для перемещения камеры мы будем получать текущую позицию игрока и при каждом обновлении кадра менять позицию камеры. А для того, чтобы персонаж двигался именно по траектории, куда направлена камера, необходимо добавить applyEuler.

Player.jsx
Player.jsx

Пояснение по коду

Метод applyEuler применяет вращение к вектору на основе заданных углов Эйлера. В данном случае вращение камеры применяется к вектору direction. Это используется для соответствия движения относительно ориентации камеры, чтобы игрок двигался в том направлении, куда повёрнута камера.

Немного поправим размер Player и сделаем его выше относительно куба, увеличив размер CapsuleCollider и поправив логику “прыжка”.

Player.jsx
Player.jsx
Перемещение камеры
Перемещение камеры

Код раздела:

Генерация кубов

Чтобы сцена не ощущалась совсем пустой, добавим генерацию кубов. В файле json перечислим координаты каждого из кубов и затем отобразим их на сцене. Для этого создадим файл cubes.json, в котором перечислим массив координат.

[
  [0, 0, -7],
  [2, 0, -7],
  [4, 0, -7],
  [6, 0, -7],
  [8, 0, -7],
  [10, 0, -7]
]

В файле Cube.jsx создадим компонент Cubes, в котором будет осуществляться генерация кубов в цикле. А компонент Cube будет являться непосредственно генерируемым объектом.

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

Добавим созданный компонент Cubes в компонент App, удалив предыдущий одиночный куб.

App.jsx
App.jsx
App.jsx
import {PointerLockControls, Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";
import {Physics} from "@react-three/rapier";
import {Player} from "./Player.jsx";
import {Cubes} from "./Cube.jsx";

export const App = () => {
    return (
        <>
            <PointerLockControls />
            <Sky sunPosition={[100, 20, 100]}/>
            <ambientLight intensity={1.5} />
            <Physics gravity={[0, -20, 0]}>
                <Ground />
                <Player />
                <Cubes />
            </Physics>
        </>
    )
}

export default App

Генерация кубов
Генерация кубов

Код раздела

Импортируем модель в проект

Теперь займёмся добавлением 3D-модели на сцену. Добавим модель оружия для персонажа. К примеру, возьмём вот такую.

Скачаем модель в формате GLTF и распакуем архив в корне проекта. 

Для того чтобы получить нужный нам формат для импорта модели на сцену, потребуется установить дополнительный пакет gltf-pipeline.

npm i -D gltf-pipeline

При помощи пакета gltf-pipeline реконвертируем модель из формата GLTF в формат GLB, так как в этом формате все данные модели помещаются в один файл. В качестве выходной директории для сгенерированного файла указываем папку public.

gltf-pipeline -i weapon/scene.gltf -o public/weapon.glb

Затем необходимо сгенерировать react-компонент, который будет содержать разметку этой модели, для добавления её на сцену. Воспользуемся официальным ресурсом от разработчиков @react-three/fiber.

Перейдя в конвертер, потребуется загрузить конвертированный файл weapon.glb. При помощи перетаскивания или поиска через Проводник найдём этот файл и загрузим его.

Конвертированная модель
Конвертированная модель

В конвертере мы увидим сгенерированный react-компонент, код которого перенесём в наш проект в новый файл WeaponModel.jsx, изменив название самого компонента на одноимённое с файлом.

Код раздела

Отображение модели оружия на сцене

Теперь импортируем созданную модель на сцену. В файле App.jsx добавим компонент WeaponModel.

App.jsx
App.jsx
App.jsx
import {PointerLockControls, Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";
import {Physics} from "@react-three/rapier";
import {Player} from "./Player.jsx";
import {Cubes} from "./Cube.jsx";
import {WeaponModel} from "./WeaponModel.jsx";

export const App = () => {
    return (
        <>
            <PointerLockControls />
            <Sky sunPosition={[100, 20, 100]}/>
            <ambientLight intensity={1.5} />
            <Physics gravity={[0, -20, 0]}>
                <Ground />
                <Player />
                <Cubes />
            </Physics>

            <group position={[0, 3, 0]}>
                <WeaponModel />
            </group>
        </>
    )
}

export default App

Демонстрация импортированной модели
Демонстрация импортированной модели

Код раздела

Добавление теней

В данный момент на нашей сцене ни один из предметов не отбрасывает тени.

Чтобы включить тени на сцене необходимо добавить атрибут shadows для компонента Canvas.

main.jsx
main.jsx
main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import {Canvas} from "@react-three/fiber";

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <div id="container">
        <div className="aim"></div>
        <Canvas camera={{ fov: 45 }} shadows>
            <App />
        </Canvas>
    </div>
  </React.StrictMode>,
)

Далее необходимо добавить новый источник освещения. Несмотря на то что мы уже имеем на сцене ambientLight, он не может создавать тени для объектов, так как у него нет направленного луча света. Поэтому добавим новый источник освещения directionalLight и настроим его. Атрибутом для включения режима “отбрасывание” тени является castShadow. Именно добавление этого параметра указывает, что данный объект может отбрасывать тень на другие предметы.

App.jsx
App.jsx
App.jsx
import {PointerLockControls, Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";
import {Physics} from "@react-three/rapier";
import {Player} from "./Player.jsx";
import {Cubes} from "./Cube.jsx";
import {WeaponModel} from "./WeaponModel.jsx";

export const App = () => {
    return (
        <>
            <PointerLockControls />
            <Sky sunPosition={[100, 20, 100]}/>
            <ambientLight intensity={1.5} />
            <directionalLight
                castShadow
                intensity={.8}
                position={[50, 50, 0]} />
            <Physics gravity={[0, -20, 0]}>
                <Ground />
                <Player />
                <Cubes />
            </Physics>

            <group position={[3, 1, -2]}>
                <WeaponModel />
            </group>
        </>
    )
}

export default App

После этого добавим другой атрибут receiveShadow в компоненте Ground, который означает, что компонент на сцене может принимать и отображать тени на себе.

Ground.jsx
Ground.jsx
Ground.jsx
import * as THREE from "three";
import { useTexture } from "@react-three/drei";
import floorTexture from "./assets/floor.png";
import {CuboidCollider, RigidBody} from "@react-three/rapier";

export const Ground = () => {
    const texture = useTexture(floorTexture);
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping;

    return (
        <RigidBody type="fixed" colliders={false}>
            <mesh receiveShadow position={[0, 0, 0]} rotation-x={-Math.PI / 2}>
                <planeGeometry args={[500, 500]} />
                <meshStandardMaterial color="gray" map={texture} map-repeat={[100, 100]} />
            </mesh>
            <CuboidCollider args={[500, 2, 500]} position={[0, -2, 0]}/>
        </RigidBody>
    );
}

Модель отбрасывает тень
Модель отбрасывает тень

Аналогичные атрибуты необходимо добавить другим объектам на сцене: кубам и игроку. Для кубов мы добавим castShadow и receiveShadow, потому что они могут как отбрасывать, так и принимать тени, а для игрока добавим только castShadow.

Добавим castShadow для Player.

Player.jsx
Player.jsx
Player.jsx
import * as THREE from "three";
import * as RAPIER from "@dimforge/rapier3d-compat"
import {CapsuleCollider, RigidBody, useRapier} from "@react-three/rapier";
import {useRef} from "react";
import {usePersonControls} from "./hooks.js";
import {useFrame} from "@react-three/fiber";

const MOVE_SPEED = 5;
const direction = new THREE.Vector3();
const frontVector = new THREE.Vector3();
const sideVector = new THREE.Vector3();

export const Player = () => {
    const playerRef = useRef();
    const { forward, backward, left, right, jump } = usePersonControls();

    const rapier = useRapier();

    useFrame((state) => {
        if (!playerRef.current) return;

        // moving player
        const velocity = playerRef.current.linvel();

        frontVector.set(0, 0, backward - forward);
        sideVector.set(left - right, 0, 0);
        direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED).applyEuler(state.camera.rotation);

        playerRef.current.wakeUp();
        playerRef.current.setLinvel({ x: direction.x, y: velocity.y, z: direction.z });

        // jumping
        const world = rapier.world;
        const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 }));
        const grounded = ray && ray.collider && Math.abs(ray.toi) <= 1.5;

        if (jump && grounded) doJump();

        // moving camera
        const {x, y, z} = playerRef.current.translation();
        state.camera.position.set(x, y, z);
    });

    const doJump = () => {
        playerRef.current.setLinvel({x: 0, y: 8, z: 0});
    }

    return (
        <>
            <RigidBody colliders={false} mass={1} ref={playerRef} lockRotations>
                <mesh castShadow>
                    <capsuleGeometry args={[0.5, 0.5]}/>
                    <CapsuleCollider args={[0.75, 0.5]} />
                </mesh>
            </RigidBody>
        </>
    );
}

Добавим castShadow и receiveShadow для Cube.

Cube.jsx
Cube.jsx
Cube.jsx
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>
    );
}

Все объекты на сцене отбрасывают тень
Все объекты на сцене отбрасывают тень

Код раздела

Добавление теней - исправление обрезки тени

Если сейчас присмотреться, то можно обнаружить, что площадь поверхности, на которую отбрасывается тень совсем небольшая. И при выходе за пределы этой площади тень просто обрезается.

Обрезание тени
Обрезание тени

Происходит это по той причине, что по умолчанию камера захватывает лишь небольшую область отображаемых теней от directionalLight. Мы можем для компонента directionalLight при помощи добавления дополнительных атрибутов shadow-camera-(top, bottom, left, right) расширить эту область видимости. После добавления этих атрибутов, тень станет немного размытой. Для улучшения качества добавим атрибут shadow-mapSize.

App.jsx
App.jsx
App.jsx
import {PointerLockControls, Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";
import {Physics} from "@react-three/rapier";
import {Player} from "./Player.jsx";
import {Cubes} from "./Cube.jsx";
import {WeaponModel} from "./WeaponModel.jsx";

const shadowOffset = 50;

export const App = () => {
    return (
        <>
            <PointerLockControls />
            <Sky sunPosition={[100, 20, 100]}/>
            <ambientLight intensity={1.5} />
            <directionalLight
                castShadow
                intensity={1.5}
                shadow-mapSize={4096}
                shadow-camera-top={shadowOffset}
                shadow-camera-bottom={-shadowOffset}
                shadow-camera-left={shadowOffset}
                shadow-camera-right={-shadowOffset}
                position={[100, 100, 0]}
            />
            <Physics gravity={[0, -20, 0]}>
                <Ground />
                <Player />
                <Cubes />
            </Physics>

            <group position={[3, 1, -2]}>
                <WeaponModel />
            </group>
        </>
    )
}

export default App

Код раздела

Привязка оружия к персонажу

Теперь добавим отображение оружия от первого лица. Создадим новый компонент Weapon, который будет содержать в себе логику поведения оружия и саму 3D-модель.

import {WeaponModel} from "./WeaponModel.jsx";

export const Weapon = (props) => {
    return (
        <group {...props}>
            <WeaponModel />
        </group>
    );
}

Поместим этот компонент на одном уровне с RigidBody персонажа и в хуке useFrame будем задавать позицию и угол вращения на основе положения значений от камеры.

Player.jsx
Player.jsx
Player.jsx
import * as THREE from "three";
import * as RAPIER from "@dimforge/rapier3d-compat"
import {CapsuleCollider, RigidBody, useRapier} from "@react-three/rapier";
import {useRef} from "react";
import {usePersonControls} from "./hooks.js";
import {useFrame} from "@react-three/fiber";
import {Weapon} from "./Weapon.jsx";

const MOVE_SPEED = 5;
const direction = new THREE.Vector3();
const frontVector = new THREE.Vector3();
const sideVector = new THREE.Vector3();
const rotation = new THREE.Vector3();

export const Player = () => {
    const playerRef = useRef();
    const { forward, backward, left, right, jump } = usePersonControls();
    const objectInHandRef = useRef();

    const rapier = useRapier();

    useFrame((state) => {
        if (!playerRef.current) return;

        // moving player
        const velocity = playerRef.current.linvel();

        frontVector.set(0, 0, backward - forward);
        sideVector.set(left - right, 0, 0);
        direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED).applyEuler(state.camera.rotation);

        playerRef.current.wakeUp();
        playerRef.current.setLinvel({ x: direction.x, y: velocity.y, z: direction.z });

        // jumping
        const world = rapier.world;
        const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 }));
        const grounded = ray && ray.collider && Math.abs(ray.toi) <= 1.5;

        if (jump && grounded) doJump();

        // moving camera
        const {x, y, z} = playerRef.current.translation();
        state.camera.position.set(x, y, z);

        // moving object in hand for the player
        objectInHandRef.current.rotation.copy(state.camera.rotation);
        objectInHandRef.current.position.copy(state.camera.position).add(state.camera.getWorldDirection(rotation));
    });

    const doJump = () => {
        playerRef.current.setLinvel({x: 0, y: 8, z: 0});
    }

    return (
        <>
            <RigidBody colliders={false} mass={1} ref={playerRef} lockRotations>
                <mesh castShadow>
                    <capsuleGeometry args={[0.5, 0.5]}/>
                    <CapsuleCollider args={[0.75, 0.5]} />
                </mesh>
            </RigidBody>
            <group ref={objectInHandRef}>
                <Weapon position={[0.3, -0.1, 0.3]} scale={0.3} />
            </group>
        </>
    );
}

Отображение модели оружия от первого лица
Отображение модели оружия от первого лица

Код раздела

Анимация покачивания оружия при ходьбе

Чтобы походка персонажа была более естественной, мы добавим небольшое покачивание оружия во время движения. Для создания анимации мы воспользуемся установленной библиотекой tween.js.

Компонент Weapon обернём в тег group, чтобы можно было добавить ссылку на него через хук useRef.

Player.jsx
Player.jsx

Добавим несколько состояний useState для сохранения анимации.

Player.jsx
Player.jsx

Создадим функцию для инициализации анимации.

Player.jsx
Player.jsx

Пояснение по коду

  1. const twSwayingAnimation = new TWEEN.Tween(currentPosition) …
    Создание анимации "качания" объекта от его текущей позиции к новой позиции.

  2. const twSwayingBackAnimation = new TWEEN.Tween(currentPosition) …
    Создание анимации возврата объекта обратно к его начальной позиции после завершения первой анимации.

  3. twSwayingAnimation.chain(twSwayingBackAnimation);
    Соединение двух анимаций, чтобы после завершения первой анимации автоматически начиналась вторая.

В useEffect вызовем функцию инициализации анимации.

Player.jsx
Player.jsx

Теперь необходимо определить момент, в течение которого происходит движение. Сделать это можно при помощи определения текущего вектора направление персонажа.

Если движение персонажа происходит, то будем обновлять анимацию и при окончании запускать ещё раз.

Player.jsx
Player.jsx

Пояснение по коду

  1. const isMoving = direction.length() > 0;
    Здесь проверяется состояние движения объекта. Если вектор direction имеет длину больше 0, то это значит, что у объекта есть направление движения.

  2. if (isMoving && isSwayingAnimationFinished) { ... }
    Это состояние выполняется, если объект двигается, а анимация “качания” завершилась.

Player.jsx
import * as THREE from "three";
import * as RAPIER from "@dimforge/rapier3d-compat"
import * as TWEEN from "@tweenjs/tween.js";
import {CapsuleCollider, RigidBody, useRapier} from "@react-three/rapier";
import {useEffect, useRef, useState} from "react";
import {usePersonControls} from "./hooks.js";
import {useFrame} from "@react-three/fiber";
import {Weapon} from "./Weapon.jsx";

const MOVE_SPEED = 5;
const direction = new THREE.Vector3();
const frontVector = new THREE.Vector3();
const sideVector = new THREE.Vector3();
const rotation = new THREE.Vector3();

export const Player = () => {
    const playerRef = useRef();
    const { forward, backward, left, right, jump } = usePersonControls();
    const objectInHandRef = useRef();

    const swayingObjectRef = useRef();
    const [swayingAnimation, setSwayingAnimation] = useState(null);
    const [swayingBackAnimation, setSwayingBackAnimation] = useState(null);
    const [isSwayingAnimationFinished, setIsSwayingAnimationFinished] = useState(true);

    const rapier = useRapier();

    useFrame((state) => {
        if (!playerRef.current) return;

        // moving player
        const velocity = playerRef.current.linvel();

        frontVector.set(0, 0, backward - forward);
        sideVector.set(left - right, 0, 0);
        direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED).applyEuler(state.camera.rotation);

        playerRef.current.wakeUp();
        playerRef.current.setLinvel({ x: direction.x, y: velocity.y, z: direction.z });

        // jumping
        const world = rapier.world;
        const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 }));
        const grounded = ray && ray.collider && Math.abs(ray.toi) <= 1.5;

        if (jump && grounded) doJump();

        // moving camera
        const {x, y, z} = playerRef.current.translation();
        state.camera.position.set(x, y, z);

        // moving object in hand for the player
        objectInHandRef.current.rotation.copy(state.camera.rotation);
        objectInHandRef.current.position.copy(state.camera.position).add(state.camera.getWorldDirection(rotation));

        const isMoving = direction.length() > 0;

        if (isMoving && isSwayingAnimationFinished) {
            setIsSwayingAnimationFinished(false);
            swayingAnimation.start();
        }

        TWEEN.update();
    });

    const doJump = () => {
        playerRef.current.setLinvel({x: 0, y: 8, z: 0});
    }

    const initSwayingObjectAnimation = () => {
        const currentPosition = new THREE.Vector3(0, 0, 0);
        const initialPosition = new THREE.Vector3(0, 0, 0);
        const newPosition = new THREE.Vector3(-0.05, 0, 0);
        const animationDuration = 300;
        const easing = TWEEN.Easing.Quadratic.Out;

        const twSwayingAnimation = new TWEEN.Tween(currentPosition)
            .to(newPosition, animationDuration)
            .easing(easing)
            .onUpdate(() => {
                swayingObjectRef.current.position.copy(currentPosition);
            });

        const twSwayingBackAnimation = new TWEEN.Tween(currentPosition)
            .to(initialPosition, animationDuration)
            .easing(easing)
            .onUpdate(() => {
                swayingObjectRef.current.position.copy(currentPosition);
            })
            .onComplete(() => {
                setIsSwayingAnimationFinished(true);
            });

        twSwayingAnimation.chain(twSwayingBackAnimation);

        setSwayingAnimation(twSwayingAnimation);
        setSwayingBackAnimation(twSwayingBackAnimation);
    }

    useEffect(() => {
        initSwayingObjectAnimation();
    }, []);

    return (
        <>
            <RigidBody colliders={false} mass={1} ref={playerRef} lockRotations>
                <mesh castShadow>
                    <capsuleGeometry args={[0.5, 0.5]}/>
                    <CapsuleCollider args={[0.75, 0.5]} />
                </mesh>
            </RigidBody>
            <group ref={objectInHandRef}>
                <group ref={swayingObjectRef}>
                    <Weapon position={[0.3, -0.1, 0.3]} scale={0.3} />
                </group>
            </group>
        </>
    );
}

В компонент App добавим useFrame, в котором будем обновлять tween-анимацию.

App.jsx
App.jsx

TWEEN.update() обновляет все активные анимации в библиотеке TWEEN.js. Этот метод вызывается на каждом кадре анимации для обеспечения плавного выполнения всех анимаций.

App.jsx
import * as TWEEN from "@tweenjs/tween.js";
import {PointerLockControls, Sky} from "@react-three/drei";
import {Ground} from "./Ground.jsx";
import {Physics} from "@react-three/rapier";
import {Player} from "./Player.jsx";
import {Cubes} from "./Cube.jsx";
import {useFrame} from "@react-three/fiber";

const shadowOffset = 50;

export const App = () => {
    useFrame(() => {
        TWEEN.update();
    });

    return (
        <>
            <PointerLockControls />
            <Sky sunPosition={[100, 20, 100]}/>
            <ambientLight intensity={1.5} />
            <directionalLight
                castShadow
                intensity={1.5}
                shadow-mapSize={4096}
                shadow-camera-top={shadowOffset}
                shadow-camera-bottom={-shadowOffset}
                shadow-camera-left={shadowOffset}
                shadow-camera-right={-shadowOffset}
                position={[100, 100, 0]}
            />
            <Physics gravity={[0, -20, 0]}>
                <Ground />
                <Player />
                <Cubes />
            </Physics>
        </>
    )
}

export default App

Анимация покачивания оружия
Анимация покачивания оружия

Код раздела:

Анимация отдачи

Нам нужно определить момент, когда происходит выстрел — то есть при нажатии кнопки мыши. Добавим useState для хранения этого состояния, useRef для сохранения ссылки на объект оружия, а также два обработчика событий для нажатия и отпускания клавиши мыши.

Weapon.jsx
Weapon.jsx
Weapon.jsx
Weapon.jsx
Weapon.jsx
Weapon.jsx

Реализуем анимацию отдачи при клике кнопкой мыши. Будем для этого использовать библиотеку tween.js

Определим константы для силы отдачи и длительности анимации.

Weapon.jsx
Weapon.jsx

Как и при анимации покачивания оружия, добавим два состояния useState для анимации отдачи и возврата в начальное положение и состояние со статусом окончания анимации.

Weapon.jsx
Weapon.jsx

Сделаем функции для получения случайного вектора анимации отдачи - generateRecoilOffset и generateNewPositionOfRecoil.

Weapon.jsx
Weapon.jsx

Создадим функцию для инициализации анимации отдачи. Также добавим useEffect, в котором в качестве зависимости укажем состояние “выстрела”, чтобы при каждом выстреле заново происходила инициализация анимации и генерировались новые конечные координаты.

Weapon.jsx
Weapon.jsx
Weapon.jsx
Weapon.jsx

А в useFrame добавим проверку “удержания” клавиши мыши для стрельбы, чтобы анимация стрельбы не прекращалась до тех пор, пока клавиша не будет отпущена.

Weapon.jsx
Weapon.jsx
Weapon.jsx
import * as THREE from "three";
import * as TWEEN from "@tweenjs/tween.js";
import {WeaponModel} from "./WeaponModel.jsx";
import {useEffect, useRef, useState} from "react";
import {useFrame} from "@react-three/fiber";

const recoilAmount = 0.03;
const recoilDuration = 100;
const easing = TWEEN.Easing.Quadratic.Out;

export const Weapon = (props) => {
    const [recoilAnimation, setRecoilAnimation] = useState(null);
    const [recoilBackAnimation, setRecoilBackAnimation] = useState(null);
    const [isShooting, setIsShooting] = useState(false);
    const weaponRef = useRef();

    useEffect(() => {
        document.addEventListener('mousedown', () => {
            setIsShooting(true);
        });

        document.addEventListener('mouseup', () => {
            setIsShooting(false);
        });
    }, []);

    const generateRecoilOffset = () => {
        return new THREE.Vector3(
            Math.random() * recoilAmount,
            Math.random() * recoilAmount,
            Math.random() * recoilAmount,
        )
    }

    const generateNewPositionOfRecoil = (currentPosition) => {
        const recoilOffset = generateRecoilOffset();
        return currentPosition.clone().add(recoilOffset);
    }

    const initRecoilAnimation = () => {
        const currentPosition = new THREE.Vector3(0, 0, 0);
        const initialPosition = new THREE.Vector3(0, 0, 0);
        const newPosition = generateNewPositionOfRecoil(currentPosition);

        const twRecoilAnimation = new TWEEN.Tween(currentPosition)
            .to(newPosition, recoilDuration)
            .easing(easing)
            .onUpdate(() => {
                weaponRef.current.position.copy(currentPosition);
            });

        const twRecoilBackAnimation = new TWEEN.Tween(currentPosition)
            .to(initialPosition, recoilDuration)
            .easing(easing)
            .onUpdate(() => {
                weaponRef.current.position.copy(currentPosition);
            });

        twRecoilAnimation.chain(twRecoilBackAnimation);

        setRecoilAnimation(twRecoilAnimation);
        setRecoilBackAnimation(twRecoilBackAnimation);
    }

    const startShooting = () => {
        recoilAnimation.start();
    }

    useEffect(() => {
        initRecoilAnimation();

        if (isShooting) {
            startShooting();
        }
    }, [isShooting]);

    useFrame(() => {
        TWEEN.update();

        if (isShooting) {
            startShooting();
        }
    });

    return (
        <group {...props}>
            <group ref={weaponRef}>
                <WeaponModel />
            </group>
        </group>
    );
}

Анимация отдачи
Анимация отдачи

Код раздела

Анимация при бездействии

Реализуем анимацию “бездействия” для персонажа, чтобы не создавалось ощущения “зависнувшей” игры.

Для этого добавим несколько новых состояний через useState.

Player.jsx
Player.jsx

Поправим инициализацию анимации “покачивания”, чтобы использовать значения из состояния. Идея заключается в том, что при разных состояниях: ходьба или остановка, - будут задействованы различные значения для анимации и каждый раз анимация будет инициализирована сначала.

Player.jsx
Player.jsx
Player.jsx
import * as THREE from "three";
import * as RAPIER from "@dimforge/rapier3d-compat"
import * as TWEEN from "@tweenjs/tween.js";
import {CapsuleCollider, RigidBody, useRapier} from "@react-three/rapier";
import {useEffect, useRef, useState} from "react";
import {usePersonControls} from "./hooks.js";
import {useFrame} from "@react-three/fiber";
import {Weapon} from "./Weapon.jsx";

const MOVE_SPEED = 5;
const direction = new THREE.Vector3();
const frontVector = new THREE.Vector3();
const sideVector = new THREE.Vector3();
const rotation = new THREE.Vector3();

export const Player = () => {
    const playerRef = useRef();
    const { forward, backward, left, right, jump } = usePersonControls();
    const objectInHandRef = useRef();

    const swayingObjectRef = useRef();
    const [swayingAnimation, setSwayingAnimation] = useState(null);
    const [swayingBackAnimation, setSwayingBackAnimation] = useState(null);
    const [isSwayingAnimationFinished, setIsSwayingAnimationFinished] = useState(true);
    const [swayingNewPosition, setSwayingNewPosition] = useState(new THREE.Vector3(-0.005, 0.005, 0));
    const [swayingDuration, setSwayingDuration] = useState(1000);
    const [isMoving, setIsMoving] = useState(false);

    const rapier = useRapier();

    useFrame((state) => {
        if (!playerRef.current) return;

        // moving player
        const velocity = playerRef.current.linvel();

        frontVector.set(0, 0, backward - forward);
        sideVector.set(left - right, 0, 0);
        direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED).applyEuler(state.camera.rotation);

        playerRef.current.wakeUp();
        playerRef.current.setLinvel({ x: direction.x, y: velocity.y, z: direction.z });

        // jumping
        const world = rapier.world;
        const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 }));
        const grounded = ray && ray.collider && Math.abs(ray.toi) <= 1.5;

        if (jump && grounded) doJump();

        // moving camera
        const {x, y, z} = playerRef.current.translation();
        state.camera.position.set(x, y, z);

        // moving object in hand for the player
        objectInHandRef.current.rotation.copy(state.camera.rotation);
        objectInHandRef.current.position.copy(state.camera.position).add(state.camera.getWorldDirection(rotation));

        setIsMoving(direction.length() > 0);

        if (swayingAnimation && isSwayingAnimationFinished) {
            setIsSwayingAnimationFinished(false);
            swayingAnimation.start();
        }
    });

    const doJump = () => {
        playerRef.current.setLinvel({x: 0, y: 8, z: 0});
    }

    const setAnimationParams = () => {
        if (!swayingAnimation) return;

        swayingAnimation.stop();
        setIsSwayingAnimationFinished(true);

        if (isMoving) {
            setSwayingDuration(() => 300);
            setSwayingNewPosition(() => new THREE.Vector3(-0.05, 0, 0));
        } else {
            setSwayingDuration(() => 1000);
            setSwayingNewPosition(() => new THREE.Vector3(-0.01, 0, 0));
        }
    }

    const initSwayingObjectAnimation = () => {
        const currentPosition = new THREE.Vector3(0, 0, 0);
        const initialPosition = new THREE.Vector3(0, 0, 0);
        const newPosition = swayingNewPosition;
        const animationDuration = swayingDuration;
        const easing = TWEEN.Easing.Quadratic.Out;

        const twSwayingAnimation = new TWEEN.Tween(currentPosition)
            .to(newPosition, animationDuration)
            .easing(easing)
            .onUpdate(() => {
                swayingObjectRef.current.position.copy(currentPosition);
            });

        const twSwayingBackAnimation = new TWEEN.Tween(currentPosition)
            .to(initialPosition, animationDuration)
            .easing(easing)
            .onUpdate(() => {
                swayingObjectRef.current.position.copy(currentPosition);
            })
            .onComplete(() => {
                setIsSwayingAnimationFinished(true);
            });

        twSwayingAnimation.chain(twSwayingBackAnimation);

        setSwayingAnimation(twSwayingAnimation);
        setSwayingBackAnimation(twSwayingBackAnimation);
    }

    useEffect(() => {
        setAnimationParams();
    }, [isMoving]);

    useEffect(() => {
        initSwayingObjectAnimation();
    }, [swayingNewPosition, swayingDuration]);

    return (
        <>
            <RigidBody colliders={false} mass={1} ref={playerRef} lockRotations>
                <mesh castShadow>
                    <capsuleGeometry args={[0.5, 0.5]}/>
                    <CapsuleCollider args={[0.75, 0.5]} />
                </mesh>
            </RigidBody>
            <group ref={objectInHandRef}>
                <group ref={swayingObjectRef}>
                    <Weapon position={[0.3, -0.1, 0.3]} scale={0.3} />
                </group>
            </group>
        </>
    );
}

Idle анимация
Idle анимация

Код раздела

Заключение

В данной части мы реализовали генерацию сцены и перемещение персонажа. Также добавили модель оружия, анимацию отдачи при стрельбе и при бездействии. В следующей части мы продолжим дорабатывать нашу игру, добавляя новый функционал.

Комментарии (4)


  1. cczzttgxkzbvxn
    30.09.2023 17:37
    +2

    Кажется, что react не супер решение для gamedev


    1. MAXH0
      30.09.2023 17:37
      +1

      Почему Вам так кажется? Вы не могли бы дать развернутое обоснование тезиса именно в в плане web-gamedev?


    1. john_samilin
      30.09.2023 17:37
      +1

      действительно, почему? кажется, в гемдеве модно использовать ECS, а react + какой-нибудь mobx - это почти то же самое


  1. anakhaev_web
    30.09.2023 17:37
    +1

    Спасибо! Надо будет попробоваать этот стек