
Самой успешной моей статьей для сообщества был подробный отчет о разработке браузерного 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 остается глотком свежего аудиовизуального-интерактивного воздуха в унылой рутине фронтенда, дает безбрежное поле для увлекательных экспериментов и творчества. Дерзайте!
