Введение

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

В сегодняшней статье мы реализуем:

  • добавим новую территорию;

  • подключим typescript и настроим абсолютные пути к файлам;

  • добавим счетчик патронов;

  • добавим перезарядку;

  • скроем точку прицела во время прицеливания;

  • поработаем со звуками при выстреле и при пустом магазине;

  • добавим интерфейс слотов быстрого доступа с иконками.

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

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

Добавляем новую территорию

Чтобы немного разнообразить территорию, по которой может перемещаться персонаж, было решено её заменить. На официальном сайте React Three Fiber есть список примеров. Я выбрал данное демо. Из него я взял модель территории и импортировал её в свой проект. Так как данная модель комнаты уже содержит всё необходимое, то мы можем её сразу использовать в нашем проекте. Для этого в файле Ground.jsx понадобится удалить всё лишнее и использовать импортированную модель. Я добавлю её по пути public/territory.glb. Данную модель (при нажатии на ссылку произойдёт скачивание) можно взять напрямую из репозитория.

И вместе с этим можно удалить текстуру поверхности пола floor.png из проекта.

Ground.jsx
Ground.jsx
Новая территория
Новая территория

Код раздела

Подключение TypeScript и SCSS

Чтобы было удобнее разрабатывать и поддерживать приложение, подключим TypeScript. В данной части я буду его использовать исключительно для разработки некоторой части интерфейса, но пока не для работы с Three.js

Например, как добавить TypeScript к уже созданному проекту, можно подсмотреть здесь.

Добавим два конфигурационных файла: tsconfig.json и tsconfig.node.json, как сказано в статье выше.

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": ["node"],

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

tsconfig.node.json

{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.js"]
}

Для упрощения импортов сделаем их с абсолютным путём. Например, в этих статьях описываются конфиги для файлов JS и TS. Для файлов TS уже было добавлено необходимое правило (8-11 строки в файле tsconfig.json). Для обычных файлов JS необходимо создать новый файл jsonconfig.json и добавить туда соответствующие правила.

jsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

В файле App.jsx теперь можно изменить пути на абсолютные, начиная пути с “@/” и убедиться, что всё работает корректно.

Чтобы во время работы не возникло ошибок с ESLint для конструкций из TypeScript понадобится отредактировать файл .eslintrc.cjs. А также установить соответствующие пакеты.

npm i -D @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser
.eslintrc.cjs
.eslintrc.cjs

Для подключение SCSS потребуется сначала установить его.

npm i -D sass

Переименуем файл index.css в index.scss. Также изменим расширение файла у main.jsx на main.tsx и поправим путь до компонента App и файла стилей в соответствии с абсолютным путём.

Ошибка TypeScript
Ошибка TypeScript

После переименования в расширение .tsx потребуется в файле index.html заменить путь к корневому файлу.

index.html
index.html

Для исправления этих ошибок потребуется в корне папки src создать файл declaration.d.ts со следующими строками.

declaration.d.ts

declare module '*.scss';
declare module '*.jsx';

Вернёмся к стилям. Создадим новый файл по пути src/UI/UI.module.scss. Перенесём в созданный файл стили “прицела” и воспользуемся “модульными классами”.

main.tsx
main.tsx

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

Код раздела

Счётчик патронов

Сейчас наше оружие может стрелять бесконечно долго, так как не имеет никакого лимита по выстрелам. Чтобы реализовать этот лимит, будем использовать zustand для хранения состояний.

Создадим новый файл по пути src/store/RoundsStore.ts. В данном файле будем использовать TypeScript. Зададим количество патронов по умолчанию, опишем интерфейс нашего хранилища и создадим само хранилище. В нём у нас будет описано: текущее количество патронов (по умолчанию задаётся значение из переменной defaultCountOfRounds), функция для уменьшения на один патрон и функция для перезарядки (установка текущего количества патронов числом по умолчанию).

RoundsStore.ts

import {create} from "zustand";

const defaultCountOfRounds = 30;

export interface IRoundsStore {
    countRounds: number;
    decreaseRounds: () => void;
    reloadRounds: () => void;
}

export const useRoundsStore = create<IRoundsStore>()((set) => ({
    countRounds: defaultCountOfRounds,
    decreaseRounds: () => set(({ countRounds }) => {
        return {
            countRounds: Math.max(countRounds - 1, 0)
        }
    }),
    reloadRounds: () => set(() => {
        return {
            countRounds: defaultCountOfRounds
        }
    })
}));

Теперь мы можем показать визуально текущее состояние количества патронов. По следующему пути создаём два файла: src/UI/NumberOfRounds/NumberOfRounds.tsx и src/UI/NumberOfRounds/styles.module.scss.

Добавим стили для элемента счётчика.

styles.module.scss

.rounds {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 60px;
  height: 60px;
  position: absolute;
  right: 40px;
  bottom: 40px;
  background: rgba(255, 255, 255, .8);
  font-size: 40px;
  border-radius: 10px;
}

Теперь реализуем сам визуальный элемент счётчика. В файле NumberOfRounds.tsx потребуется воспользоваться состоянием useRoundsStore и получить текущее количество патронов. А затем будем выводить либо количество оставшихся патронов, либо кнопку, которую потребуется нажать. Чтобы сделать приложение более гибким, было бы хорошо вынести “название кнопки” в конфигурацию проекта. В .env добавим два новых поля: одно для отображаемого названия кнопки, второе с кодом клавиши, которую потребуется нажать.

.env
.env

Теперь реализуем файл NumberOfRounds.tsx.

import {useRoundsStore, IRoundsStore} from "@/store/RoundsStore.ts";
import styles from "@/UI/NumberOfRounds/styles.module.scss";

const RELOAD_BUTTON_NAME = import.meta.env.VITE_RELOAD_BUTTON_NAME;

const NumberOfRounds = () => {
    const countOfRounds = useRoundsStore((state: IRoundsStore) => state.countRounds);
    const isEmptyRounds = countOfRounds === 0;

    return (
        <div className={styles.rounds}>
            {!isEmptyRounds ? countOfRounds : RELOAD_BUTTON_NAME}
        </div>
    );
};

export default NumberOfRounds;

Однако по той причине, что TypeScript всё строго проверяет, то он в данный момент не знает, что вообще может существовать такой конфигурационный файл. А также есть шанс, что в файле .env может не оказаться этой переменной и тогда в процессе работы приложения появится ошибка. Поэтому потребуется внести некоторые изменения, чтобы валидация кода начала срабатывать корректно.

В файл tsconfig.json добавим ещё одно значение для types.

tsconfig.json
tsconfig.json

Также в файле declaration.d.ts добавим интерфейс, который будет описывать структуру нашего .env, чтобы TypeScript мог всё корректно воспринимать.

declaration.d.ts
declaration.d.ts

После внесённых изменений все ошибки из файла должны будут пропасть.

Теперь требуется вывести счётчик на экран. При этом, у нас в данный момент существует ещё один элемент интерфейса - прицел. Поэтому теперь мы можем отделить элементы интерфейса от элементов 3D. Для этого создадим новый файл src/UI/UI.tsx

Небольшое отступление. Так как у нас появилась отдельная папка для хранения состояний, то теперь мы можем перенести одно из состояний туда, которое нам понадобится прямо сейчас, а именно: useAimingStore. Создадим файл в src/store/AimingStore.ts и приведём его к следующему виду. Из файла Weapon.jsx удалим это состояние и добавим импорты этого состояния в файлах Weapon.jsx и Player.jsx.

AimingStore.ts

import {create} from "zustand";

export interface IAimingStore {
    isAiming: boolean;
    setIsAiming: (value: boolean ) => void;
}

export const useAimingStore = create<IAimingStore>()((set) => ({
    isAiming: false,
    setIsAiming: (value) => set(() => ({ isAiming: value }))
}));
Weapon.jsx
Weapon.jsx
Player.jsx
Player.jsx

И теперь реализуем файл UI.tsx. Перенесём в него прицел, который будет отображаться только в том случае, если игрок не прицеливается через клавишу мыши, а также добавим туда только что созданный компонент NumberOfRounds.

UI.tsx

import NumberOfRounds from "@/UI/NumberOfRounds/NumberOfRounds.tsx";
import {IAimingStore, useAimingStore} from "@/store/AimingStore.ts";
import styles from "@/UI/UI.module.scss";

const UI = () => {
    const isAiming = useAimingStore((state: IAimingStore) => state.isAiming);

    return (
        <div className="ui-root">
            {!isAiming && <div className={styles.aim} />}
            <NumberOfRounds/>
        </div>
    );
};

export default UI;

Теперь при каждом выстреле необходимо уменьшать количество на один патрон. Для этого понадобится вызывать функцию decreaseRounds из useRoundsStore. А в то время, как количество патронов будет равно 0, то вызвать функцию reloadRounds. При этом, в данный момент за один клик будет срабатывать два выстрела, поэтому необходимо поправить логику начала выстрелов.

Weapon.jsx
Weapon.jsx

Код раздела

Перезарядка

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

Все изменения будут происходить только в файле Weapon.jsx. Понадобится импортировать из .env код клавиши для перезарядки. Затем получить состояние и функцию из useRoundsStore, а также добавить обработчик события keypress и сравнивать нажатую клавишу с той клавишей, которая задана для перезарядки. А в функции startShooting проверять количество патронов, и если оно равно 0, то не производить никаких действий. Соответственно, если выстрелить все патроны, а затем нажать на клавишу R, то все действия и звуки снова начнут воспроизводиться.

Код раздела

Звуки при выстреле и при пустом магазине

Займёмся звуками оружия. В данный момент уже реализован звук при выстреле. Но при попытке выстрелить с пустым магазином нет никакого звука. Мною уже подобран звук, поэтому его (при нажатии на ссылку начнётся скачивание) можно загрузить из коммита текущего раздела. 

Также, вместо того, чтобы использовать HTMLAudio, можно воспользоваться пространственным звуком, который добавляется на сцену и динамически меняется в зависимости от различных факторов. Для этого необходимо добавить тег PositionalAudio. Также потребуется добавить несколько атрибутов:

  • url - указывается ссылка на аудио-файл;

  • autoplay - указываем false, так как данный звук вызывается только при определенном действии;

  • loop - указываем false, так как необходимо единичное воспроизведение;

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

Код раздела

Интерфейс слотов быстрого доступа

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

Начнём с реализации хранилища. Создадим файл src/store/QuickAccessSlotsStore.ts. Опишем тип слота QuickAccessSlotsType, что в нём хранится какой-то элемент в виде строки (в данном случае, это будет пока что только иконка предмета), а также интерфейс IQuickAccessSlotsStore, в котором указано, что слоты - это массив типов. А также создаём само хранилище.

QuickAccessSlotsStore.ts

import {create} from "zustand";
import default_slots from "@/quick_access_slots.json";

const SLOTS_COUNT = 5 as const;

type QuickAccessSlotsType = {
    item: string;
}

interface IQuickAccessSlotsStore {
    slots: QuickAccessSlotsType[];
}

export const useQuickAccessSlotsStore = create<IQuickAccessSlotsStore>()(() => ({
    slots: default_slots.concat(Array(SLOTS_COUNT - default_slots.length).fill({
        item: null
    }))
}));

Создадим пробный файл json, в котором будет массив некоторых добавленных предметов. А также загрузим несколько иконок, которые мы будем в дальнейшем использовать.

quick_access_slots.json

[
  {
    "item": "medicine/aid.png"
  },
  {
    "item": "weapons/mp5.png"
  },
  {
    "item": "weapons/ak.png"
  }
]

Теперь необходимо создать сам интерфейс для пользователя. Это будет некоторое количество слотов с иконкой внутри и цифрой для активации данного предмета. 

Создадим два файла для стилей и макета: src/UI/QuickAccessSlots/styles.module.scss и src/UI/QuickAccessSlots/QuickAccessSlots.tsx

Напишем стили.

.slots {
  position: fixed;
  bottom: 40px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  column-gap: 10px;

  .slot {
    position: relative;
    width: 60px;
    height: 60px;
    border-radius: 10px;
    border: 2px solid darkgray;
    background: rgba(255, 255, 255, .3);
    padding: 5px;
    transform: skew(-20deg, 0);

    img {
      width: 100%;
      height: 100%;
    }

    .key {
      position: absolute;
      z-index: 2;
      font-weight: bold;
    }
  }
}

В tsx файле извлекаем слоты из хранилища и выводим их через цикл, добавляя внутрь каждого слота цифру (индекс элемента + 1), а также проверяем, заполнен ли чем-то слот и если да, то выводим изображение.

QuickAccessSlots.tsx

import {useQuickAccessSlotsStore} from "@/store/QuickAccessSlotsStore.ts";
import styles from "@/UI/QuickAccessSlots/styles.module.scss";

const QuickAccessSlots = () => {
    const slots = useQuickAccessSlotsStore((state) => state.slots);

    return (
        <div className={styles.slots}>
            {slots.map((slot, key) => (
                <div key={key} className={styles.slot}>
                    <span className={styles.key}>{key + 1}</span>
                    {slot.item && <img src={`/images/icons/${slot.item}`} alt={`${slot.item} ICON`}/>}
                </div>
            ))}
        </div>
    );
};

export default QuickAccessSlots;

И в конце добавляем созданный компонент в корневой компонент UI.tsx.

UI.tsx
UI.tsx

Код раздела

Заключение

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

Спасибо за прочтение и буду рад ответить на комментарии!

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


  1. MAXH0
    18.01.2024 03:34
    +1

    Просто запасся попкорном и жду окончания. Интересно насколько итоговый код будет работоспособен. Я абсолютно не специалист в вопросе, но что-то (опыт игр) наводит на размышления о том, что именно шутер будет лагать.

    НО сама статья хороша. Разбирает все аспекты подробно и пошагово. Спасибо.


    1. varlab Автор
      18.01.2024 03:34

      Спасибо за комментарий. Какого-то прям четкого финала я не вижу, потому что эту идею можно развивать бесконечно долго. А слишком много аспектов остались ещё нетронутыми.

      Итоговый код уже работоспособен, потому что оно в принципе работает) Я бы не стал писать в статью неработающий код. Но я так понимаю, что Вы имели ввиду именно работоспособен=играбелен=хорошая оптимизация. Однако, я и не претендую на то, что в конечном итоге получится ААА-игра, потому что, конечно, здесь всё-таки есть дополнительные обёртки, которые съедают производительность, но в целом потенциал есть. И, конечно, всё будет зависеть от того, насколько потребуется улучшить оптимизацию (у меня игра пока норм летает, но и каждый у себя может запустить этот код, чтобы проверить). Я лишь пытаюсь сделать так, чтобы в дальнейшем то, что описано в статье, можно было разбить на мелкие кусочки и отдельно использовать, если это где-то пригодится. Потому что, всё-таки, данную технологию можно использовать для абсолютно любой 3Д-графики, и не обязательно делать только игру. Думаю, кто-то обязательно подчерпнёт какую-то полезную для себя информацию.