Олды здесь? )
Олды здесь? )

Самой успешной моей статьей для сообщества был подробный отчет о разработке браузерного FPS. Судя по статистике в базе данных — неожиданно огромное количество людей зашло и попробовало сыграть, я получал заинтересованные вопросы в личку и так далее. В дальнейшем, я предпринял еще одну попытку крафтового браузерного геймдева «на javascript», и попробовал создать конструктор для стратегии в духе культовой Dune из детства. В какой-то момент я уперся в неудовлетворительную производительность получающейся разработки, заскучал и уже почти год как забросил это дело. Но у меня вполне получилось построить работающий полноценный контрол, сейчас можно возводить и демонтировать здания. Поэтому хочу, прежде всего, поставить точку для себя самого, немного рассказав и о данной затее — возможно, для кого-то окажутся полезными мои усилия, изыскания. Статья не будет такой объемной, дотошной и разнообразной как первая о создании действительно полноценного шутера, зато сам код репозитория, кажется, немного интереснее, так как использует более актуальный стек из Vue3 и TypeScript. Во многом, эта разработка продолжает идеи и методы первой, с тем отличием, что мы пилим стратегию, а не шутер от первого лица. Я совсем не буду повторять то что было уже пройдено и рассмотрено на первом примере, бегло покажу только «новые фичи».

Демо-стенд.

Репо.

Статья будет организована, как «краткая обзорная экскурсия» по важным файлам, модулям и концептам проекта.

Конфигурация

Можно сказать, что код репозитория в своей структуре в общем и целом повторяет любой обычный проект фронтенда на схожих технологиях. Поэтому «самым первым местом» все также является файл предоставляющий остальному коду перечни всевозможных имен игровых объектов-сущностей, цветов-текстур, констант конфигурации геймплея, переводы текстов: @/src/utils/constants.ts

Контрол

Построить
Построить

То что, кажется, вполне может пригодиться кому-то на «подсмотреть» — это законченный контрол на основе MapControl, который умеет «выставлять» постройки при зажатой клавише Tab, а при зажатом Space превращаться в «групповой выделитель». Все это обрабатывается в основном компоненте Сцены, который предоставляет «всего один див» — для Three, необходимые стандартные компоненты библиотеки и кастомизацию контрола игры.  @/src/components/Scene/Scene.vue.

Выделить
Выделить

Модули

Думаю, что для реализации задачи написания конструктора базы классической стратегии [в отличии от шутера] намного больше подходит выбор типизированного языка, так как здесь здесь мы можем и должны сосредоточится именно на проектировании структуры. И «самым интересным местом» в репозитории, на мой взгляд, является файл описания используемых для построения игры интерфейсов и модулей. Начинается он с самой сакральной вещи во всей кухне — интерфейса «глобального объекта» — общего для всех модулей-сущностей контекста, который мы собираем в корневом компоненте.

// В @/src/models/modules.ts:
// Main object
export interface ISelf {
 // Utils
 helper: Helper; // "наше все" - набор рабочих функций, инкапсулирующий всю логику, обсчеты и тем самым - "экономящий память" ))
 assets: Assets; // модуль загружающий все ассеты - текстуры, объекты-модели и звуки
 events: Events; // шина событий
 audio: AudioBus; // аудиомикшер

 // Core
 store: Store<State>;
 scene: Scene;
 listener: AudioListener;
 render: () => void;
}

Вот этот архиважный момент был несколько многословно обойден в первой статье. Если кратко, то в корневом компоненте мы «инициализируем и в дальнейшем анимируем вообще все» — предоставляя этому всему глобальный контекст сцены. Очевидно, что, таким образом, мы обеспечиваем доступ любым дочерним модулям ко всем важным компонентам системы, и, что самое главное во всем этом — можем «экономить память» ради лучшей производительности, инкапсулируя переиспользуюемую логику. На js в шутере мы могли делать вот так, в «сцене» Scene.vue:

// Инициализируем модуль “мира” (инициализируйший все остальные модули-объекты)
this.world = new World();
this.world.init(this);

Где World и любые дочерние модули которые он в свою очередь порождает и анимирует это что-то вроде:

function Module() {
  this.init = (
    scope,
    texture,
    material,
    // ...
  ) => {};


  this.animate = (scope) => {};
}

export default Module;
На ts, в Сцене:
<template>
  <div id="scene" class="scene" :class="isSelection && 'scene--selection'" />
</template>

<script lang="ts">
// ...

// Types
import type { ISelf } from '@/models/modules';
// ...

export default defineComponent({
  name: 'Scene',

  setup() {
    const store = useStore(key);

    // Core

    let container: HTMLElement;

    let camera: PerspectiveCamera = new THREE.PerspectiveCamera();
    let listener: AudioListener = new THREE.AudioListener();

    let scene: Scene = new THREE.Scene();

    let renderer: WebGLRenderer = new THREE.WebGLRenderer({
      antialias: true,
    });

    // Helpers
    let helper: Helper = new Helper();
    let assets: Assets = new Assets();
    let events: Events = new Events();
    let audio: AudioBus = new AudioBus();

    // Modules
    let world = new World();

   // Functions
    let init: () => void;
    let animate: () => void;
    let render: () => void;
    let onWindowResize: () => void;
    // ...

    // Store getters
    const isPause = computed(() => store.getters['layout/isPause']);
    // ..
    
    // ...

    // Go!
    init = () => {
      // Core
      container = document.getElementById('scene') as HTMLElement;

      // ...

      // Listeners
      window.addEventListener('resize', onWindowResize, false);
      // ...

      // Modules
      assets.init(self);
      audio.init(self);
      world.init(self);

      // First render
      onWindowResize();
      render();
    };

    // ...

    animate = () => {
      if (!isPause.value) {
        world.animate(self);

        render();
      }

      // ...

      requestAnimationFrame(animate);
    };

    onWindowResize = () => {
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();

      renderer.setSize(window.innerWidth, window.innerHeight);
    };


    render = () => {
      renderer.render(scene, camera);
      // console.log('Renderer info: ', renderer.info.memory.geometries, renderer.info.memory.textures, renderer.info.render);

      // ...
    };

    // This is self )
    let self: ISelf = {
      // Utils
      helper,
      assets,
      events,
      audio,

      // Core
      store,
      scene,
      listener,
      render,
    };

    // ...

    onMounted(() => {
      init();
      animate();
    });

    // ...
  },
});
</script>

TS заставляет писать «прямо как настоящие серьезные программисты», более выразительно, явно и аккуратно, используя классовый синтаксис. Вот давайте проследим «путь одного модуля постройки» от абстракции, к его реальной конечной реализации:

В @/src/models/modules.ts:
// Interfaces
///////////////////////////////////////////////////////

// Статичный модуль без копий - например Атмосфера
export interface ISimpleModule {
 init(self: ISelf): void;
}

// Модули
interface IModule extends ISimpleModule {
 isCanAdd(self: ISelf, vector: Vector3, name?: Names): boolean;
 add(self: ISelf, vector: Vector3, name?: Names): void;
 remove(self: ISelf, items: string[], name?: Names): void;
}

// Aнимированные модули
interface IAnimatedModule extends IModule {
 animate(self: ISelf): void;
}

// Модули с копиями
interface IModules extends IModule {
 initItem(self: ISelf, item: TObject, isStart: boolean): void;
}

// Анимированные модули с копиями
interface IAnimatedModules extends IAnimatedModule {
 initItem(self: ISelf, item: TObject, isStart: boolean): void;
}

// Abstract
///////////////////////////////////////////////////////

// Статичный модуль без копий - например Атмосфера
export abstract class SimpleModule implements ISimpleModule {
 constructor(public name: Names) {
   this.name = name;
 }

 // Инициализация
 public abstract init(self: ISelf): void;
}

// Обертки и модули
abstract class Module extends SimpleModule implements IModule {
 constructor(public name: Names) {
   super(name);
 }

 // Можно ли добавить новый объект?
 public abstract isCanAdd(self: ISelf, vector: Vector3, name?: Names): boolean;

 // Добавить новую единицу
 public abstract add(self: ISelf, vector: Vector3, name?: Names): void;

 // Убрать объекты
 public abstract remove(self: ISelf, items: string[], name?: Names): void;
}

// Анимированный модуль
export abstract class AnimatedModule extends Module implements IAnimatedModule {
 constructor(public name: Names) {
   super(name);
 }

 // Анимация
 public abstract animate(self: ISelf): void;
}

//  Модули
abstract class Modules extends Module implements IModules {
 constructor(public name: Names) {
   super(name);
 }

 // Инициализировать новую единицу
 public abstract initItem(self: ISelf, item: TObject, isStart: boolean): void;
}

// Анимированные модули
abstract class AnimatedModules extends Modules implements IAnimatedModules {
 constructor(public name: Names) {
   super(name);
 }

 // Анимация
 public abstract animate(self: ISelf): void;
}

// Real
///////////////////////////////////////////////////////

// Обертки
export class Wrapper extends AnimatedModule implements IAnimatedModule {
 constructor(public name: Names) {
   super(name);
 }

 // Инициализация
 public init(self: ISelf): void {
   console.log('modules.ts', 'Wrapper', 'init ', this.name, self);
 }

 // Можно ли добавить новый объект?
 public isCanAdd(self: ISelf, vector: Vector3, name?: Names): boolean {
   console.log('modules.ts', 'Wrapper', 'isCanAdd ', vector, name);
   return false;
 }

 // Добавить объект
 public add(self: ISelf, vector: Vector3, name?: Names): void {
   console.log('modules.ts', 'Wrapper', 'add ', vector, name);
 }

 // Удалить объекты
 public remove(self: ISelf, items: string[], name?: Names): void {
   console.log('modules.ts', 'Wrapper', 'remove ', items, name);
 }

 // Анимация
 public animate(self: ISelf): void {
   console.log('modules.ts', 'Wrapper', 'animate ', this.name, self);
 }
}

// Строения
export class Builds extends Modules implements IModules {
 constructor(public name: Names) {
   super(name);
 }

 // Инициализация
 public init(self: ISelf): void {
   console.log('modules.ts', 'Builds', 'init ', this.name, self);
 }

 // Инициализация одного объекта
 public initItem(self: ISelf, item: TObject, isStart: boolean): void {
   console.log(
     'modules.ts',
     'Builds',
     'initItem ',
     this.name,
     self,
     item,
     isStart,
   );
 }

 // Можно ли добавить новый объект?
 public isCanAdd(self: ISelf, vector: Vector3): boolean {
   return self.helper.isCanAddItemHelper(self, vector, this.name);
 }

 // Удалить объекты
 public remove(self: ISelf, items: string[]): void {
   self.helper.sellHelper(self, items, this.name);
 }

 // Добавить объект
 public add(self: ISelf, vector: Vector3): void {
   self.helper.addItemHelper(self, this, vector);
 }
}

Теперь у нас есть класс Строений и мы можем создать два его более конкретных случая — когда инициализация должна использовать простую геометрию и когда мы подгружаем модель:

Статичные строения без моделей:
export class StaticSimpleBuilds extends Builds {
 public geometry!: BoxBufferGeometry;
 public material!: MeshStandardMaterial;

 constructor(public name: Names) {
   super(name);
 }

 // Инициализация одного объекта
 public initItem(self: ISelf, item: TObject, isStart: boolean): void {
   self.helper.initItemHelper(
     self,
     this.name,
     this.geometry,
     this.material,
     item,
     isStart,
   );
 }

 public init(self: ISelf): void {
   // Форма
   this.geometry = getGeometryByName(this.name);

   // Материал
   this.material = new THREE.MeshStandardMaterial({
     color: Colors[this.name as keyof typeof Colors],
     map: self.assets.getTexture(this.name), // Текстура
   });

   // Инициализация
   self.helper.initModulesHelper(self, this);
 }
}

Статичные строения c моделью:
export class StaticModelsBuilds extends Builds {
 public model!: GLTF;

 constructor(public name: Names) {
   super(name);
 }

 // Инициализация одного объекта
 public initItem(self: ISelf, item: TObject, isStart: boolean): void {
   self.helper.initItemFromModelHelper(
     self,
     this.name,
     this.model,
     item,
     isStart,
   );
 }

 public init(self: ISelf): void {
   // Модель
   self.assets.GLTFLoader.load(
     `./images/models/${this.name}.glb`,
     (model: GLTF) => {
       // Прелоадер
       self.helper.loaderDispatchHelper(self.store, `${this.name}IsLoaded`);
       this.model = self.helper.traverseHelper(self, model, this.name);

       // Инициализация
       self.helper.initModulesHelper(self, this);
       self.render();
     },
   );
 }
}

Как вы видите все конкретные реализации отдельных переиспользуемых функций, обсчеты-проверки и даже несколько полезных публичных переменных сосредоточены в классовом модуле-помощнике Helper (справедливости ради — кроме совсем примитивной-атомарной getGeometryByName из набора простейших утилит). Проброс глобального контекста позволяет нам из любого места логики взаимодействовать с самой сценой (когда нужно, например, удалить объект Three), с модулями хранилица или модулем загрузчиков-ассетов, шиной событий и аудиошиной.

Теперь мы можем иметь две «группирующие обертки» — собственно сам World, его «дочерний» тип Build, представляющий все строения. А все конкретные низовые постройки теперь описываются вот такими вот совсем простыми классами:

// Constants
import { Names } from '@/utils/constants';

// Modules
import { ModuleType } from '@/models/modules';

export default class ModuleName extends ModuleType {
 constructor() {
   super(Names.modulename);
 }
}

Ого! Вот в этом месте мы выписываем радикально жирнючий плюс тайпскрипту! Ведь если сравнить лапшеобразный хаотичный код модулей в первом проекте (например) — уровень организации во втором просто поражает.)

Сетка

Игровой стол
Игровой стол

В шутере для создание основы окружающего мира — пола и строений, указания мест зарезервированных для мелких игровых объектов я использовал glb-модель с некоторой оптимальной частью мира — текущим уровнем-локацией. Здесь же само собой напрашивается использование «сетки», «доски» — упрощенной модели пространства с помощью которой мы можем контролировать расположение строений и объектов окружающего мира.

Состояние

Понятно, что нужно уметь делать две вещи — сохранять состояние мира и игры — положение-направление контрола, показателей игрового процесса и всех игровых объектов при перезагрузке страницы, а также сбрасывать его на стартовое при желании. На самом деле подобное часто требуется и в обычных веб-интерфейсах, и сегодня реализуется совсем просто с помощью стороннего «персистора».  Подключаем готовый модуль к хранилищу и указываем ему части которые хотим сохранять.

Я выделил два модуля — «лейаут», который содержит элементы геймлея и состояния (только что заметил — флага isDesignPanel в нем быть не должно — так как при перезагрузке с открытой панелью контруктора — она «залипнет» пока не будет снова нажат таб), и «объекты» — который содержит информацию о сетке (что уже есть на данной ячейке?) и всех объектах.

В хранилище сетки и объектов также находится важный флаг isStart который нужен чтобы отличать стартовый дефолтный запуск игры. Можно посмотреть в модуле отвечающем «за обстановку и окружение» Atmosphere.ts, который, например, рандомно генерит горы из столбиков при запуске приложения «с чистого листа» или восстанавливает их из хранилища в остальных случаях:

Генерация гор заново или восстановление:
// ...

this._positions = [];
if (self.store.getters['objects/isStart']) {
 this._objects = [];

 // Генерируем горы заново
 for (let n = 0; n < DESIGN.ATMOSPHERE_ELEMENTS[Names.stones]; ++n) {
   this._meshes = new THREE.Group();

   this._position = getUniqueRandomPosition(
     this._positions,
     0,
     0,
     10,
     DESIGN.SIZE / DESIGN.CELL / 12.5,
     false,
   );
   this._positions.push(this._position);

   // ...

   this._objects.push({
     name: Names.stones,
     id: '',
     data: this._object,
   });

   self.scene.add(this._meshes);
 }

 // Сохраняем в хранилище
 self.store.dispatch('objects/saveObjects', {
   name: Names.stones,
   objects: this._objects,
 });
} else {
 // Восстанавливаем горы из хранилища
 this._objects = [...self.store.getters['objects/objects'][Names.stones]];

 this._objects.forEach((group) => {
   this._meshes = new THREE.Group();

   group.data.forEach((stone: TStone) => {
     this._position = { x: stone.x, z: stone.z };
     this._height = stone.h;

     self.helper.geometry = new THREE.BoxBufferGeometry(
       DESIGN.CELL,
       DESIGN.CELL * this._height,
       DESIGN.CELL,
     );

     this._mesh = new THREE.Mesh(
       self.helper.geometry,
       self.helper.material,
     );
     this._mesh.position.set(
       this._position.x * DESIGN.CELL,
       OBJECTS.sand.positionY,
       this._position.z * DESIGN.CELL,
     );
     this._mesh.name = Names.stones;

     this._meshes.add(this._mesh);
   });
   self.scene.add(this._meshes);
 });
}

// ...

При нажатии на кнопку «Начать сначала» на экране Паузы (по Esc потому что у нас не PointerLockControls — см. статью о шутере) вызывается вот такая цепочка обещаний с window.location.reload(true); в самом конце. Понятно что она вызывает последовательный сброс всех хранилищ кроме модуля прелоадера на дефолт:

// Помощник перезагрузки
export const restartDispatchHelper = (store: Store<State>): void => {
  store
    .dispatch('layout/setField', {
      field: 'isReload',
      value: true,
    })
    .then(() => {
      store
        .dispatch('game/reload')
        .then(() => {
          store
            .dispatch('objects/reload')
            .then(() => {
              store
                .dispatch('layout/reload')
                .then(() => {
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                  // @ts-ignore
                  window.location.reload(true);
                })
                .catch((error) => {
                  console.log(error);
                });
            })
            .catch((error) => {
              console.log(error);
            });
        })
        .catch((error) => {
          console.log(error);
        });
    })
    .catch((error) => {
      console.log(error);
    });
};

Вывод

Я получил массу удовольствия от этой попытки. С другой стороны, когда я представил что мне придется дальше «заставлять танчики ездить и стрелять, взаимодействовать», «дымится и взрываться» — приуныл и «засушил весла», «поднял лапки». Объектов уже очень много и они не двигаются. Но при этом FPS уже сейчас катастрофически проседает вплоть «до заметного зависания» при «групповом выделении» (хотя, скорее всего, проблема банальная и легко фиксится - «слишком часто происходит pointermove» - и «нужно его отроттлить»).. Или когда браузеру приходится микшировать большое количество PositionalAudio — слышен неприятный треск вместо саундтрека. Но, безусловно — Three.js остается глотком свежего аудиовизуального-интерактивного воздуха в унылой рутине фронтенда, дает безбрежное поле для увлекательных экспериментов и творчества. Дерзайте!

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


  1. hi_ppl
    26.12.2022 02:04
    +2

    А прикольно. Даже не знал, что существуют такие прикольные решения для 3D в браузере. Без тяжеловесных движков. Большое спасибо, что поделились!