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