Этой статьей я хочу представить сообществу разработку www.ruinsworld.ru, которой, по сути, посвятил пять последних лет жизни. Все начиналось с браузерного сингл‑шутера, потом была не очень удачная и быстро наскучившая попытка в стратегию, после чего я поставил себе, казалось бы, невозможную задачу. Реально ли, используя все эти наработки, построить многопользовательский шутер в браузере, да еще не просто «стрелялку внутри небольшой коробки», а с большим открытым миром и огромным количеством неписей в нем? Чтобы можно было «идти куда хочешь во все стороны и делать что заблагорассудится», как в самых лучших постапокалиптических РПГ?

За эти годы было много ночного сидения, бесконечные ручные тесты до посинения и судорог, встречался я и с моментами отчаяния, когда казалось, что некоторые проблемы решить невозможно. Но, в результате, терпеливо пришел к нынешней минимальной реализации, которая предоставляет ограниченный геймплей для любого количества клиентов в постоянно изменяющемся мире. Сейчас сервер с бета‑версией игры запущен на всего одну локацию, потому что мне доступен только обычный VPS, а выделенный это очень‑очень дорого. Если бы сервер был мощный, их могло бы быть по крайней мере девять. В игре пока только один вид оружия у двух игровых рас (гранаты), три обычных предмета, один редкий, несколько видов неписей.

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

Код игры полностью открыт и доступен в двух репозиториях: клиент и сервер.

И создал я мир.. Шовный или бесшовный?

Тут «огненный» мем!

Где‑то сильно уже после середины долгого и тяжелого экспериментального пути по разработке игры на не сильно предназначенных для этого кривом языке и странной среде, я, наконец, разорился и купил модную книжку про «паттерны объектно‑ориентированного программирования в контексте геймдев». Книжка, кстати, оказалась на удивление захватывающе легкой и годной, несмотря на то, что от американского автора. Но! Мне правда не удалось почерпнуть в ней каких‑то на самом деле прорывных новых идей для себя, в плане, например, оптимизации производительности игрового цикла. Все о чем я прочитал — я уже «так или иначе» — «придумал сам» в процессе своих исследований, например — достаточно очевидную необходимость использования паттерна Приспособленец для работы с большими объёмами данных. Будем предполагать что эта статья — только первая в цикле статей о моей необычной разработке — я проработал множество самых разных нюансов и деталей связанных с этим. Но самый первый материал хочется посвятить именно основным рамочным концептам, идеям и самым эффективным интересным решением для них.

Я хотел сделать огромный живущий своей жизнью «открытый мир». Открытые миры, как известно, могут быть либо «бесшовными» либо «шовными». Давайте попытаемся в контексте браузерной клиент‑серверной системы разобрать минимально технически что это именно значит. Сперва взглянем на принципиальную схему основных узлов системы и коммуникации между ними:

рис 1
рис 1

Мир это огромное количество организованных определенным образом экземпляров различных моделей, данных. Они сначала инициализируются и генерируются, а потом постоянно пересчитываются между собой в игровом цикле на сервере и визуализируются в анимационном на клиентах. По сокетам сервер постоянно шлет изменения состояния игры и принимает от контролов‑клиентов постоянные обновления. А также слушает и отвечает на отдельные немедленные сообщения. От того, как мы организуем структуру данных, хранение и передачу‑обновление состояния игры, зависит не только производительность системы, но, также, и чисто гуманитарные качества, геймплей, игровой процесс, просто задачи рисования и дизайна даже. Так, огрубляя, если некий игровой контрол сместился или непись куда‑то двинулась — это заурядно и вполне можно «накопить» в короткие обновления, отправить «всем» с очередной порцией таких перманентных изменений. Все равно — реальное положение, например, всех таких объектов на клиентах, в любом случае, локально‑субъективно и возникает через экстраполяцию. А вот если, например, на каком‑то клиенте произошел выстрел, важное специфическое редкое событие — серверу и всем остальным клиентам необходимо узнать об этом как можно скорее, прямо сейчас.

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

рис 2
рис 2

Картинка выше передает рамочный концепт по поводу того, в чем различие в этих двух подходах. В одном случае, мы организуем мир по меньшим ячейкам — в сокетах они идеально соответствуют каналам-комнатам. А во втором — должны выделять каждое соединение в отдельный канал. Очевидно, что первое — шовное — решение более контролируемо и предсказуемо по нагрузке. При некоем критическом — очень большом количестве подключений — мы все равно обслуживаем только известные 2N + 1 в квадрате локаций и можем легко это тестировать меняя N. Во втором случае нагрузка растет линейно с количеством подключений и совершенно непонятно как это реально тестировать просто. Причем, в огромном количестве случаев мы будем «отдавать одни и те же данные», очевидно. Можно, конечно, представить, что мы делаем немного более сложное гибридное решение — динамически пересобираем игроков в комнаты через интервал. Периодически объединяя «достаточно близких игроков» в примерно одинаковые кластеры-каналы (и, предположим — на фронте отбрасывая лишние для этого клиента данные). Но это вызов для будущего.

Выстрелы на фронтенде

Все проверки и обсчеты, кстати, и на сервере в игровом лупе, и на клиенте, в анимационном — также делятся на те, которые нужно сделать прямо сейчас, и те, которые можно без ущерба для игрового процесса совершать с некоторой периодичностью. Движущейся с чрезмерно большой скоростью объект, рискует «пропустить стену», даже если обсчет его столкновений совершается каждый игровой кадр. А проверку того, что «кто‑то из игроков зашел в некие отравленные зоны и должен начать от этого страдать» — вполне допустимо делать и раз в секунду, например. Кроме того, это стоит вообще делать непосредственно на каждом клиенте, который знает конфигурацию локации и «может сам справиться». В том смысле, что — да, «клиент по сути только показывает отображает данные», плюс, конечно, служит контролом — создает события управления. Но у нас в любом случае на фронте также присутствует весь математический инструментарий и нужные модели‑данные для расчета столкновений, например.

В моей реализации есть сейчас такие, кажется, необычные решения, когда имеющий достаточно информации клиент «не ждет ответа от регистрации и обработки игрового события бизнес‑логикой на сервере», или даже сам берет на себя важные решения и обсчеты, например — регистрируя столкновение «своей» гранаты с препятствием, регистрируя взрыв. Кинул выстрел, зарегистрировал для других на сервере, опа‑опа — считаешь его полет по коробкам «которые и видит стреляющий», регистрируем столкновение — кидаем всем взрыв. Вот так вот, ребзя! Серверу ведь и так у нас приходится несладко: 2N + 1 (в шовном решении и N по числу подключений в бесшовным) в квадрате локаций, для каждой — модели и тысячи объектов на них, постоянные обсчеты этого всего. В контексте нашей основной задачи, кажется более адекватным, если «самостоятельный контрол‑клиент» заберет часть нагрузки у сервера. Или двери в укрытиях — сейчас открываются только на клиенте, «субъективно». В геймплее сейчас нет вообще никаких механик для которых важно чтобы «игроки точно видели момент в который дверь открывает кто‑то другой». Например, сейчас данные и сервер содержат информацию о здоровье и уровне опыта игроков, но ничего не знают о голоде, жажде и уровне отравления! При том что это важные механики для геймплея. Ну, немного утрируя, для наглядности — вы же уверены и не станете спорить, что сервер может и должен ничего не знать об летящих красивых облаках на клиенте? Зная что мы решаем крайне сложную и критическую для производительности любого сервера задачу — очень большой мир с огромным количеством игровых объектов в нем — я в каждой ситуации думал как раз о том, чтобы именно «нигде и никогда не делать ничего лишнего во всем смыслах и самых мелких деталях», особенно на серваке.

Вы что там все спите что-ли на бекенде?

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

Сейчас уже будет много кода на  основную тему о который идет речь в статье — «архитектура шовного открытого мира и оптимизация игрового цикла в нем». Привожу его чтобы вы могли посмотреть как все устроено «в общем и целом». Если бы у нас был очень мощный сервер, много локаций в игре — следующая оптимизация на бэкенде, возможно, была бы уместна (учитывая перечисленные выше минусы).

В .env на сервере:
WORLD=1 # Размер мира - N в 2N + 1 в кдвадрате локаций
SIZE=125 # Размер одной локации в метрах
NPC_ON_LOCATION=30 # Количество неписей на локацию
SLEEP_ANIMATE=100 # Количество неписей со "спящих" локаций которые мы продвигаем в каждом игровом цикле
SLEEP_ANIMATE_MIN=50 # Минимально количество неписей со спящих локаций которые мы продвигаем в каждом цикле
LAZY_CHECKS_SECONDS=10 # Интервал в который совершаются "ленивые проверки"

В @/src/models/modules.ts:
import type { Mesh } from "three";
import type Events from "../services/utils/events";
import type Octree from "../services/math/octree";
import type EventEmitter from "events";
import { IUnitsByLocations } from "./api";
import { Lifecycle } from "./gameplay";

// Набор локаций
export interface Octrees {
  [key: string]: Octree;
}

// Main object
export interface ISelf {
  // Utils
  emiiter: EventEmitter; // шина сообщений между модулями
  events: Events; // шина игровых событий

  // Objects
  scene: { [key: string]: Mesh }; // хранилище коробок
  unitsByLocations: IUnitsByLocations; // данные о юнитах по локациям
  units: { [key: string]: string }; // сопоставление - юнит/локация

  // Math
  octrees: Octrees; // модели локаций
}

В @/models/api.ts:
export enum EmitterEvents {
  addNPC = 'addNPC', // Новая непись
  removeNPC = 'removeNPC', // Удаление неписи
  // ... Остальные события для сокетов или геймплея
}

В @/src/services/game/units/npc.ts:
// Types
import type {
  ISelf,
  IUnitCollider,
} from '../../../models/modules';
import type {
  IUnit,
} from '../../../models/api';

// Utils
import Helper from '../../utils/helper';

export default class NPC {
  public counter = 0;
  public list: IUnit[];

  private _timerStartCreate = 0;
  private _timerLazyCheck = 0;
  private _item!: IUnit;
  private _listAnimate!: IUnit[];
  private _listSleepAnimate!: IUnit[];
  private _listSleepAnimateResult!: IUnit[];
  private _number!: number;
  private _number2!: number;

  // ...

  // Добавить юнит
  private _addUnit(self: ISelf, id?: string) {
    ++this.counter;
   
    // Создаем все необходимые данные и добавляем непись в список ...

    // addNPC event emit
    self.emiiter.emit(EmitterEvents.addNPC, this._item);
  }

  // Проверка отношений
  private _checkUnit(
    self: ISelf,
    unit: IUnit,
    collider: IUnitCollider,
    height: number,
  ) {
    // ...
  }
    
  public animate(self: ISelf): void {
    // Решение на создание нового зомби после создания мира
    this._timerStartCreate += self.events.delta;
    if (this._timerStartCreate > 0.2) {
      if (
        this.counter <
        Number(process.env.NPC_ON_LOCATION) *
          Math.pow(Number(process.env.WORLD) * 2 + 1, 2)
      ) {
        this._addUnit(self);
      }
      this._timerStartCreate = 0;
    }

    // Ленивые проверки
    this._timerLazyCheck += self.events.delta;
    if (this._timerLazyCheck > 0.2) this._timerLazyCheck = 0;

    // Оптимизирующая механика
    this._listAnimate = [
      ...this.list.filter(
        (unit) => !unit.isSleep && unit.lifecycle !== Lifecycle.dead
      ),
    ];
    this._listSleepAnimate = [
      ...this.list.filter(
        (unit) => unit.isSleep && unit.lifecycle !== Lifecycle.dead
      ),
    ];

    this._number = Helper.randomInteger(0, this._listSleepAnimate.length - 1);
    this._number2 =
      this._listAnimate.length < Number(process.env.SLEEP_ANIMATE)
        ? Number(process.env.SLEEP_ANIMATE) - this._listAnimate.length
        : Number(process.env.SLEEP_ANIMATE_MIN);

    if (this._number + this._number2 < this._listSleepAnimate.length) {
      this._listSleepAnimateResult = this._listSleepAnimate.slice(
        this._number,
        this._number + this._number2
      );
    } else {
      this._listSleepAnimateResult = this._listSleepAnimate
        .slice(this._number, this._listSleepAnimate.length - 1)
        .concat(
          this._listSleepAnimate.slice(
            0,
            this._number + this._number2 - this._listSleepAnimate.length + 1
          )
        );
    }

    this._listAnimate
      .concat(this._listSleepAnimateResult)
      .forEach((unit: IUnit) => {
        // Если сброс таймера - проверяем юнита
        if (!this._timerLazyCheck)
          this._checkUnit(self, unit, this._collider, this._box.y);

        // Анимируем ...
      });
  }
}

В @/src/services/game/game.ts
import * as THREE from 'three';

// Nest
import { Injectable } from '@nestjs/common';

// Types
import type { ISelf } from '../../models/modules';
import { Fields } from '../../models/modules';
import type {
  IUnit,
  ILocationUnits,
  IUnitsByLocations,
} from '../../models/api';

// Constants
import { EmitterEvents } from '../../models/modules';
import { Lifecycle } from '../../models/gameplay';

// Modules
import NPC from './units/npc';
// ...

@Injectable()
export default class Game {
  public npc: NPC;
  // Другие модули

  private _events: Events;
  private _self: ISelf;

  private _units!: IUnitsByLocations;
  private _id!: string;
  private _ids!: string[];
  private _p1!: THREE.Vector3;
  private _p2!: THREE.Vector3;
  private _number!: number;

  private _timeLazyChecks = 0;

  constructor() {
    // Инициализация эммитера и главного объекта игры
    const EventEmitter = require('events');
    this._events = new Events();
    this._self = {
      emiiter: new EventEmitter(),
      events: this._events,
      octrees: {},
      scene: {},
      unitsByLocations: {},
      units: {},
    };

    this.npc = new NPC();
    // Инициализации остальных модулей игры
    // ...

    // Записываем данные о юнитах по локациям
    this._self.unitsByLocations = this._getUnitsByLocations();

    this._self.emiiter.on(EmitterEvents.addNPC, () => {
      this._self.unitsByLocations = this._getUnitsByLocations();
    });

    this._self.emiiter.on(EmitterEvents.removeNPC, (id) => {
      // ...
      this._self.unitsByLocations = this._getUnitsByLocations();
    });

    // Другие игровые события

    this._animate();
  }

  // Записать игровые объекты по локациям
  private _getUnitsByLocations(): IUnitsByLocations {
    this._units = {};
    // ...
    return this._units;
  }

  // Ленивая проверка неписей
  private _lazyChecks(self: ISelf): void {
    this.npc.lazyCheck(); // Убиваем слишком старых (для баланса среди неписей)
    this.things.lazyCheck(self); // Перемещаем слишком старые (для баланса)

    this._number = 0;
    this.npc.getList().forEach((npc) => {
      this._id = this.world.getLocationIdByUnitId(npc.id, Fields.npc);
      this._p1 = new THREE.Vector3(npc.positionX, npc.positionY, npc.positionZ);
      this._p2 = new THREE.Vector3(0, 0, 0);

      // Выход на другую локацию
      if (this._p1.distanceTo(this._p2) > Number(process.env.SIZE) * 0.7) {
        // ...
      }
    });
    if (this._number > 0) this._checkUnits();
  }

  // Оптимизирующая механика
  private _checkUnits() {
    if (Number(process.env.FULL_TEST) === 0) {
      this.world.array.forEach((location: ILocationUnits) => {
        this._ids = this.world.locations[location.id].npc;

        if (this.world.locations[location.id].users.length > 0) {
          this.npc.toggleSleep(this._ids, false);
        } else this.npc.toggleSleep(this._ids, true);
      });
    }
  }

  private _animate(): void {
    // console.log('Game animate delta: ', this._self.events.delta);
    this._events.animate();

    this.npc.animate(this._self);
    // Остальные модули игры ...

    // Ленивые проверки - которые можно делать редко
    this._timeLazyChecks += this._self.events.delta;
    if (this._timeLazyChecks > Number(process.env.LAZY_CHECKS_SECONDS)) {
      this._lazyChecks(this._self);

      this._timeLazyChecks = 0;
    }

    setTimeout(() => {
      this._animate();
    }, 0);
  }
}

Все это также критически важно и для «клиентского GUI». В шовном случае нам нужно решить «что происходит когда игрок достигает границы локации?», он не должен видеть «край» или «упираться в стену». (Хотя тут может быть и так, что все локации «кончаются преградой» — стеной‑горой, а «выходы‑проходы — помечены». Я думаю вы часто видели это в играх. Такой подход предотвращает проблемы которые в определенной степени присутствуют в моей реализации. Когда переход между локациями «свободный» и «может произойти в любое место» — игрок может угодить даже «внутрь камня». С другой стороны, это «более открытый мир», «против унылой коридорности»)).) В обоих решениях — шовном и бесшовном нам придется решить как предоставить пользователю хорошую красивую перспективу и «обзор в даль». Для шовного мира я нашел такие «выразительные средства»:

рис 3
рис 3
Как выглядит локация в игре «с высоты птичьего полета»:

Тут мы подходим к архитектурному моменту, которому много внимания уделено в самой первой статье о синглплеере. Я не 3D‑художник и у меня нет на это много времени, как бы ни хотелось. Поэтому вся игра сейчас собирается из самых примитивных объектов, или моделей, окрашенных всего несколькими текстурами. Самое тяжелое что подгружает клиент до того как анимационный луп будет запущен (можно не ждать с лоадером, но тогда будет заметно притормаживать в самом начале игры и часть мира будет на виду прорисовываться) — несколько скромных по весу моделей, «но, как в настоящей игре», сделанных с помощью бесплатного адобовского сайта. Такой подход позволяет достаточно быстро перезапускать приложение. Очевидно, что это адекватно нативной природе среды для которой мы разрабатываем, по крайней мере. Переход между локациями через перезагрузку длиться ровно столько же времени, как и если вы просто перезапустите браузер. У вас слабый комп, например, и «упс, что‑то зависло»))). Это даже адекватно жанру многопользовательского «быстрого выживальческого рогалика», когда вас легко могут убить неписи если вы перезагрузились вне укрытия.

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

Класс МодульНаКлиентеИлиБэкенде {
  метод Инициализация(КонтекстИгры) {
    КонтекстИгры.Излучатель.подписаться(Событие1, Загрузка) => ФункцияОбратногоВызоваНаСобытие1()
    …
  }

  метод Анимация(КонтекстИгры) {
	КонтекстИгры.Излучатель.излучить(Событие2, Загрузка)
    …
  }

  метод ЛениваяПроверкаПоТаймауту(КонтекстИгры) {
	КонтекстИгры.Излучатель.излучить(Событие3, Загрузка)
    …
  }
}

Не только лишь все

Что мы можем сделать для того чтобы разгрузить клиент? Объекты приходят на локацию «все», и их может быть очень много. Если это великое множество отображений экземпляров анимируемых моделей — браузер может и «задохнутся». Простая идея — необходимая, кстати, именно для построения «бесшовной» реализации — показывать только определенное число «близких»:

рис 4
рис 4

И опять некоторая «выжимка» кодов, на этот раз с клиента — самое важное по основным темам статьи. В .env на фронте:

VUE_APP_ITEMS=10

В @/models/api.ts:

export enum EmitterEvents {
  pick = 'pick', // Игрок подобрал предмет
  // ... Остальные события для сокетов или геймплея
}
В @/models/modules.ts:
// Types
import type { Store } from 'vuex';
import type { State } from '@/store';
import type { Scene, AudioListener, PerspectiveCamera } from 'three';
import type Helper from '@/utils/helper';
import type Assets from '@/utils/assets';
import type Events from '@/utils/events';
import type AudioBus from '@/utils/audio';
import type Octree from '@/components/Scene/World/Math/Octree';

// Interfaces
///////////////////////////////////////////////////////

export type KeysState = {
  [key: string]: boolean;
};

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

  // Math
  octree: Octree; // основное "октодерево"-мир
  octree2: Octree; // дополнительное октодерево - для движущихся объектов
  octree3: Octree; // дополнительное октодерево - для обсчета выстрелов
  octree4: Octree; // дополнительное октодерево - для дверей

  // State
  keys: KeysState; // состояние клавиш клавиатуры

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

В @/src/components/Scene/World/Enemies/NPC.ts:
import * as THREE from 'three';

// Types
import type {
  Group,
  Vector3,
} from 'three';
import type { ISelf } from '@/models/modules';
import type { IUnit, IUnitThree } from '@/models/api';

// Services
import emitter from '@/utils/emitter';

// Constants
import { EmitterEvents } from '@/models/api';
import {
  Names,
  Lifecycle,
  Picks,
} from '@/utils/constants';

export default class NPC {
  public name = Names.zombies;

  private _model1!: Group;
  private _model2!: Group;
  private _modelClone!: Group;
  private _list: IUnitThree[];
  private _listNew: IUnit[];
  private _unit!: IUnit;
  private _listNewMin: IUnit[];
  private _listNow: IUnit[];
  private _listMerge: IUnit[];
  private _idsList: string[];
  private _idsListNew: string[];
  private _time = 0;
  private _npcThree!: IUnitThree;
  private _v1!: Vector3;
  private _v2!: Vector3;
  private _isFirstAnimate = false;
  private _usersLength!: number;
  private _npcLength!: number;
  private _isReady: boolean;

  constructor() {
    this._list = [];
    this._listNew = [];
    this._listNewMin = [];
    this._listNow = [];
    this._listMerge = [];
    this._idsList = [];
    this._idsListNew = [];
    this._isReady = false;
  }

  public init(self: ISelf): void {
    // Загружаем модели NPC, оружия для них, создаем прототипы всех объектов для сцены и геймплея ...

    // Реагировать на подбор
    emitter.on(EmitterEvents.pick, (message: any) => {
      if (message.type === Picks.dead) {
        this._npcThree = this._list.find(
          (unit: IUnitThree) => unit.id === message.id,
        ) as IUnitThree;
        if (this._npcThree) {
          this._removeNPC(self, this._npcThree);
        }
      }
    });

    self.helper.loaderDispatchHelper(self.store, this.name);
  }

  // Добавить на сцену
  private _addNPC(self: ISelf, unit: IUnit): void {
    // Создаем все объекты, экземпляр данных расширенного под Three и геймплей типа для списка, запускаем микшер ...

    // В список
    this._list.push(this._npcThree);
  }

  // Выкинуть со сцены
  private _removeNPC(self: ISelf, unit: IUnitThree): void {
    this._modelClone = self.scene.getObjectByProperty(
      'uuid',
      unit.model,
    ) as Group;
    if (this._modelClone) this._modelClone.removeFromParent();
    // И все остальные объекты ...
    this._list = this._list.filter((npc) => npc.id !== unit.id);
  }

  private _animateNPC(self: ISelf, unit: IUnitThree, info: IUnit): void {
    // Для всех кроме окончательно умерших - сдвигаем объекты по данным
    if (!unit.isDead) {
      unit.positionX = info.positionX;
      unit.positionY = info.positionY;
      unit.positionZ = info.positionZ;
      unit.directionX = info.directionX;
      unit.directionY = info.directionY;
      unit.directionZ = info.directionZ;
      unit.directionW = info.directionW;
      unit.rotationY = info.rotationY;
      unit.health = info.health;
      unit.animation = info.animation;

      // Сдвигаем объекты, включаем-выключаем звуки и продвигаем анимационный миксер ...
    }
  }

  public animate(self: ISelf): void {
    // console.log('NPC: ', self.store.getters['api/game'].npc.length);
    if (!this._isReady) {
      this._isReady =
        Boolean(this._model1) &&
        Boolean(this._model2) &&
        // Все модели NPC ...
    } else {
      if (
        self.store.getters['api/game'] &&
        self.store.getters['api/game'].npc &&
        (self.store.getters['api/game'].npc.length || this._list.length)
      ) {
        this._time += self.events.delta;
        // Иногда запускаем пересборку массива "ближащих NPC"
        if (this._time > 1 || !this._list.length) {
          this._setNewList(self);
          this._time = 0;

          // Самый первый раз
          if (!this._isFirstAnimate) {
            this._isFirstAnimate = true;
            // console.log('Самый первый раз!!!');
            this._listNewMin.forEach((npc) => {
              this._addNPC(self, npc);
            });
          } else {
            // Всегда потом
            this._listMerge = [...this._listNewMin];
            this._idsList.forEach((id) => {
              if (!this._idsListNew.includes(id)) {
                this._unit = this._listNow.find(
                  (unit: IUnit) => unit.id === id,
                ) as IUnit;
                this._listMerge.push(this._unit);
              }
            });
            this._listMerge.forEach((npc) => {
              // Нет в новом списке - на удаление
              if (
                this._idsList.includes(npc.id) &&
                !this._idsListNew.includes(npc.id)
              ) {
                this._npcThree = this._list.find(
                  (unit: IUnitThree) => unit.id === npc.id,
                ) as IUnitThree;
                if (this._npcThree) {
                  this._removeNPC(self, this._npcThree);
                }
                // Нет в старом списке - на добавление
              } else if (
                !this._idsList.includes(npc.id) &&
                this._idsListNew.includes(npc.id)
              ) {
                this._addNPC(self, npc);
                // Есть и там и там - анимируем
              } else if (
                this._idsList.includes(npc.id) &&
                this._idsListNew.includes(npc.id)
              ) {
                this._npcThree = this._list.find(
                  (unit: IUnitThree) => unit.id === npc.id,
                ) as IUnitThree;
                if (this._npcThree) {
                  this._animateNPC(self, this._npcThree, npc);
                }
              }
            });
          }
          this._listNow = [...this._listNewMin];
          this._idsList = [...this._idsListNew];
        } else {
          this._listNow.forEach((npc) => {
            this._npcThree = this._list.find(
              (unit: IUnitThree) => unit.id === npc.id,
            ) as IUnitThree;
            if (this._npcThree) {
              this._animateNPC(self, this._npcThree, npc);
            }
          });
        }
      }
    }
  }

  // Оптимизация - показываем определенное количество ближайщих неписей
  private _setNewList(self: ISelf): void {
    // Не показываем только что рожденных (они "падают" на поверхность мира)
    this._listNew = JSON.parse(
      JSON.stringify(
        self.store.getters['api/game'].npc.filter(
          (unit: { lifecycle: Lifecycle }) => unit.lifecycle !== Lifecycle.born,
        ),
      ),
    );

    this._usersLength =
      self.store.getters['api/game'].users
        // Не показываем тех кто ждет загрузки при переходе локации
        .filter(
          (unit: { lifecycle: Lifecycle }) => unit.lifecycle !== Lifecycle.born,
        )
        .sort((a: IUnit, b: IUnit) => {
          this._v1 = new THREE.Vector3(a.positionX, a.positionY, a.positionZ);
          this._v2 = new THREE.Vector3(b.positionX, b.positionY, b.positionZ);

          return (
            this._v1.distanceTo(self.camera.position) -
            this._v2.distanceTo(self.camera.position)
          );
        })
        // Берем не больше 15ти ближайших
        .slice(0, 15).length - 1;

    // Берем только ближайщиx в зависимости от количества игроков в локации
    
    if (this._usersLength < 5)
      this._npcLength = Number(process.env.VUE_APP_ITEMS) - this._usersLength;
    else this._npcLength = 5;
    
    this._listNewMin = this._listNew
      .sort((a: IUnit, b: IUnit) => {
        this._v1 = new THREE.Vector3(a.positionX, a.positionY, a.positionZ);
        this._v2 = new THREE.Vector3(b.positionX, b.positionY, b.positionZ);

        return (
          this._v1.distanceTo(self.camera.position) -
          this._v2.distanceTo(self.camera.position)
        );
      })
      .slice(0, this._npcLength);
    this._idsListNew = this._listNewMin.map((npc: IUnit) => {
      return npc.id;
    });

    // Проверяем включены ли звуки на видимых
    this._listNew.forEach((npc) => {
      this._npcThree = this._list.find(
        (unit: IUnitThree) => unit.id === npc.id,
      ) as IUnitThree;
      if (this._npcThree) {
        // ...
      }
    });
  }
}

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

Просто несколько скриншотов из игры:

Мир не прост, совсем не прост...

Конечно, никто не спорит с тем, что использование Three на сервере для создания модели мира и обсчета физики/столкновений — крайне странный и спорный подход. С другой стороны, я, кажется, не знаю пока других действительно готовых для этого решений на node? Если вы подскажете — только действительно подходящее, где есть весь необходимый инструментарий — подменить им Three будет не особо сложно — кодовая база пока совсем небольшая, но, зато, конечно же, полностью аккуратная, оптимальная. По крайней мере, это в чем‑то «удобно» — «одно и тоже, и там м там», нет никаких расхождений в, предположим, «системах координат».

В процессе развертывания игры с различным размером мира (количество локаций, размер локаций, количество неписей и прочих объектов на них) и всевозможными настройками генерации на слабом сервере удалось достоверно выяснить самое слабое место всей такой системы. И, оказалось, что это даже не «объем мира», так как можно увеличивать следующие настройки достаточно сильно:

# В .env
WORLD=1
NPC_ON_LOCATION=30

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

Так вот, самое узкое место всей моей «шовной» реализации — это не «размер мира», а «сложность поверхностей его локаций для обсчета». Если формировать «октодеревья» для обсчета столкновений из слишком большого количества 3D‑объектов (Вот тут это место!) — тут мы создаем основные модели для всех локаций и еще служебные — которые содержат только основу и конусы «горок и помоек», неровности — чтобы позже корректировать по ней предметы, колодцы и декоративные камешки и железяки) — происходит быстрое «превышение кучи» в процессе построения моделей локаций мира на слабых машинах. В результате серверное приложение не запускается.

Также, в случае, если на клиенте попытаться добавить «слишком сложные стены, поверхности» на сцену — браузер просто зависнет — не справившись именно с созданием модели для обсчета. Если же, например, нужно будет «анимировать слишком много неписей» — будет просто сильно проседать и/или скакать FPS анимационного цикла.

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

Все идет по плану

Ближайшие планы по улучшению геймплея такие:

  • Несколько видов разного оружия. Не только гранаты — «быстрострельное» и лазерное. Мины.

  • Летающие неписи. Охраняющие локации от игроков дроны. Птицы для охоты.

  • Больше предметов. Предметы-модификаторы для более интересного боя.

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

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

Также, если вы попробовали покатать немного в игру — пройдите, пожалуйста, небольшой опрос.

Заранее огромное спасибо всем кто поучаствует в опросе!

UPD:

После первого часа стала понятна самая главная ошибка «по GUI» на данный момент — то что «подсказка недоступна вне игры». Поэтому многие не могут даже выйти из Укрытия. Управление пока нельзя переназначить:

Выстрел: Левая кнопка мыши

Движение: WASD

Прыжок: Space + WASD

Бежать: Shift + W

Cкрытное передвижение (меньше урон): C или Alt

Осмотреться: Мышь (Если перестало работать — нажмите P или Ecs и выберите Играть)

Оптический прицел: Правая кнопка мыши

Подсказка: H

Карта: M

Меню: P

Действие: E

Чат: R

ВНИМАНИЕ!!!
Cкорость контрола зависит от уровня здоровья персонажа и степени его отравления. Игроки не могут стрелять из тяжелого оружия когда перемешаются или прыгают. Если вы кинете из гранатомета снаряд себе под ноги или об стену прямо перед собой, то погибнете!!!

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

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


  1. ednersky
    18.12.2024 20:13

    что-то на Linux+Firefox не пошёл.

    то есть пошёл, но бежит всё время назад (хотя я клавиатуры не касаюсь) и сам стреляет.

    но статью лайкнул. Автору большой респект! Несомненно, мелочи исправятся, а наработанная экспа останется.


    1. ushliypakostnik Автор
      18.12.2024 20:13

      Ага, спасиб. У меня в винде на не игровом, но просто - хорошем мощном ноуте - работает "во всех бро". А в линуксе на слабом - не очень хорошо даже в Хром.


      1. ednersky
        18.12.2024 20:13

        если сделать десяток рефрешей, то иногда можно видеть игровой процесс (я написал в опроснике). А ещё если переключиться на другое окно, то возврат в игру только через рефреш.


  1. HemulGM
    18.12.2024 20:13

    Опечатка в заголовке


    1. ushliypakostnik Автор
      18.12.2024 20:13

      Ой! Спасибо!