Конструктор базы для браузерной стратегии в духе Dune 2/2000 на Three.js, Vue3 + TS

от автора

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

Самой успешной моей статьей для сообщества был подробный отчет о разработке браузерного 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,     texture1,     material1,     // ...   ) => {};     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;      // Store getters     const isPause = computed(() => store.getters['layout/isPause']);     // ..          // ...      // Go!     init = () => {       // Core       container = document.getElementById('scene') as HTMLElement;        // ...        // Modules       // ...       world.init(self);        // First render       // ...       render();     };      // ...      animate = () => {       if (!isPause.value) {         world.animate(self);          render();       }        // ...        requestAnimationFrame(animate);     };       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();        // audio.toggle(isPause.value);     });      // ...   }, }); </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 уже сейчас катастрофически проседает вплоть «до видимого зависания» при «групповом выделении». Или когда браузеру приходится микшировать большое количество PositionalAudio — слышен неприятный треск вместо саундтрека. Но, безусловно — Three.js остается глотком свежего воздуха в унылой рутине визуального-интерактивного фронтенда, дает безбрежное поле для увлекательных экспериментов и творчества. Дерзайте!


ссылка на оригинал статьи https://habr.com/ru/post/707518/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *