Skip to content

Latest commit

 

History

History
670 lines (509 loc) · 47.2 KB

README.md

File metadata and controls

670 lines (509 loc) · 47.2 KB

Entity

Сущность — это любой значимый объект игрового мира: игрок, питомец, шляпа, портал, подбираемый контейнер. EntityBase даёт им общий идентификатор, единое описание (вид, имя, иконка, локализация, качество), регистрацию в глобальном реестре, правила уничтожения и работу с пулом.

Всё, что должно быть найдено по типу, посчитано, сохранено, уничтожено по окончании раунда или показано в UI, — сущность. Декорации и служебные компоненты — нет.

Карта модуля

Папка Что внутри
EntityBase/ EntityBase и три варианта под разные способы задать описание
EntityMetadata/ Вид, имя, иконка, локализация, качество и механизм переопределения
EntityManager/ EntityTracker — глобальный реестр сущностей, выдача Id, статистика
EntityInstance/ Данные экземпляра: запись EntityInstanceData и интерфейс IEntityInstance
EntityStats/ Базовые характеристики, персональные модификаторы и расчёт итоговых
EntityContainer/ Подбираемые контейнеры: ресурс, действие
Player/ IPlayer, PlayerBase, PlayerTracker, команды
Initialize/ ReadySignal — сигнал готовности с подпиской «поздних» слушателей
Pickup/ Интерфейсы подбора
корень IEntity, EntityLink, время жизни, параметры уничтожения, слой времени

Базовая сущность

Самый короткий путь — компонент Entity: его вешают на префаб и выбирают ассет описания. Кода не требуется вовсе, потому что вид, имя, иконку и переводы сущность берёт из описания.

// Класс нужен, только когда у сущности появляется своё поведение.
public class Portal : Entity
{
    [SerializeField] private Transform target;
}

Если описанию нужны собственные поля, заводят наследника EntityMetadataBase и берут EntityBase<TMetadata> с ним:

public class Player : EntityBase<PlayerMetadata>
{
    // Metadata типизирован — видны поля PlayerMetadata.
}

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

Что даёт базовый класс

Возможность Как работает
Идентификатор Id выдаётся при регистрации в трекере, из Start()
Регистрация Автоматически в Start(), снятие — при уничтожении
Вид EntityType из описания; сущности с определением задают его в коде
Описание Description — имя, иконка, локализация и качество
Уничтожение DestroyEntity() с учётом пула и настройки EntityDisposeAction
Пул Реализация IPoolable через PoolBehaviour
Пауза и время Наследуется от PRMonoBehaviour; слой времени — GetTimeScaleLayer()
Мониторы Автоматически подхватывает AnimatorPauseMonitor

Какой базовый класс выбрать

Класс Откуда берётся описание Когда использовать
Entity Ассет EntityMetadata, ссылка на префабе Обычный случай: объект сцены, портал, дашборд
EntityBase<TMetadata> Ассет своего типа Когда описанию нужны собственные поля
EntityDefinition<T> Типизированный ассет-определение Питомцы, шляпы — всё, у чего есть каталог
RuntimeEntityBase Поля прямо на компоненте Контейнеры, дропы, временные объекты

EntityDefinition<T> — основной путь для контентных сущностей: PetEntity, HatEntity и сущности проектного слоя построены именно так, а их определения дополнительно поставляют модификаторы характеристик. Вид такие сущности объявляют в коде: их ассет описывает позицию каталога, а не вид.

Идентификатор

Id выдаётся при регистрации в реестре, а регистрация происходит в Start(). Это важно: в Awake() и InitializationComponents() идентификатора ещё нет.

protected override void InitializationComponents()
{
    base.InitializationComponents();
    // Здесь Id == 0 — использовать его нельзя.
}

public override void OnReadyScene()
{
    // Здесь Id уже назначен.
}

Подробности о том, в какой именно реестр попадает сущность и как ведут себя идентификаторы, — в разделе Реестры.

Данные экземпляра

Описание и определение отвечают на вопрос «что это за сущность» и общие для всех её копий. Уровень, улучшения, прочность принадлежат одному экземпляру. Их хранит запись EntityInstanceData, а сущность, которой она нужна, реализует IEntityInstance. Остальных сущностей это не касается.

Член записи Что хранит
InstanceId Идентификатор экземпляра. Сохраняется между запусками, в отличие от IEntity.Id
DefinitionId Идентификатор определения, по которому сущность создают заново
Set / Get / TryGet Свои значения по ключам EnumerationType<T>
Changed Значение записали или забыли. Запись того же значения событие не поднимает
IsValid() Оба идентификатора на месте: по такой записи предмет можно восстановить
Fits(definitionId) Запись относится к сущности этого определения

В запись идёт постоянное: то, что должно пережить сохранение и перенос в инвентарь. То, что верно только сейчас — «в руках», «идёт откат», текущая цель, — остаётся полями самой сущности и в запись не попадает.

public static class PickaxeInstanceKeys
{
    public static readonly EnumerationType<long> Level = new(nameof(Level));
}

public class PickaxeEntity : EntityDefinition<PickaxeDefinition>, IEntityInstance
{
    public EntityInstanceData Instance { get; private set; }

    public long Level => Instance.Get(PickaxeInstanceKeys.Level, 1);

    public EntityInstanceData CreateInstance()
    {
        return EntityInstanceData.Create(Definition.Id);
    }

    public void SetInstance(EntityInstanceData data)
    {
        Instance = data;
        RecalculateStats();   // всё, что выводится из записи, считается здесь
    }
}

Когда ставится запись

Запись ставится одним методом — SetInstance. EntityBase зовёт его перед каждой InitializeEntity() при создании сущности и выдаче из пула, поэтому в InitializeEntity() запись уже есть, а сущность из пула не приходит с уровнем прошлой жизни.

Сущность с сохранённой записью создают через EntityInstanceSpawn:

var entity = EntityInstanceSpawn.Create(
    (EntityInstanceData)saved.Clone(),
    () => PRUnitySDK.Managers.ObjectPool.ShowEntity(definition.Prefab, parent));

Тогда база поставит именно эту запись вместо новой, и InitializeEntity() посчитает всё по сохранённым значениям. Создавать сущность внутри функции можно как угодно: пулом или Instantiate.

Запись достаётся ровно одной сущности — той, которую вернула функция:

Что произошло внутри функции Итог
Сущность начала жизнь и её определение совпало с записью запись стоит до InitializeEntity()
Вместе с ней проснулась дочерняя сущность другого определения та получает свою новую запись
Вместе с ней проснулась вложенная сущность того же определения вложенная запись не берёт, она достаётся внешней
Запись раньше забрала соседняя сущность того же определения той ставится новая запись, эта переходит вернувшейся; обе уже прошли InitializeEntity не со своей записью — в лог уходит предупреждение
Сущность создана выключенной и жизнь не начала запись ставится следом, через SetInstance, и переживает её первый Awake
Вернулась сущность другого определения ошибка в лог, запись ей не передаётся
Экземпляр с таким InstanceId уже живёт на сцене ошибка в лог, функция создания не вызывается, Create возвращает null

Удалась ли передача, проверяют сравнением: ReferenceEquals(entity.Instance, record). Тот, кто после создания убирает запись из своего контейнера, делает это только при совпадении — иначе предмет пропал бы вместе с ошибкой.

Вложенные вызовы Create друг другу не мешают: после внутреннего возвращается запись внешнего.

SetInstance можно позвать и у живой сущности, но InitializeEntity() к этому времени уже прошла. Поэтому всё, что сущность выводит из записи — характеристики, вид, подписи, — пересчитывают в SetInstance, а не в InitializeEntity().

Сущность, возвращённая из спрятанных (RestoreHideEntities), остаётся тем же экземпляром: InitializeEntity() проходит заново, а запись и InstanceId не меняются.

Кто владеет записью

У записи один владелец. Отдавая запись сущности, инвентарь убирает её у себя; подбирая сущность, забирает запись обратно. Два места, которые правят один и тот же объект записи, дают предмет, существующий дважды.

Нужно Чем
Отдать запись сущности или контейнеру сам объект записи, прежний владелец о нём забывает
Сохранить состояние Clone() — снимок того же экземпляра, InstanceId прежний
Сделать второй такой же предмет Duplicate() — новая запись с новым InstanceId

Коллекцию записей в ProjectData при клонировании (MethodHookStage.Cloning) копируют глубоко, через Clone() каждой записи: иначе снимок, ждущий записи на диск, меняется вместе с живыми предметами.

Живёт ли экземпляр сущностью на сцене, отвечает EntityInstanceOwners.Find(instanceId). Своего учёта он не ведёт, а спрашивает EntityTracker; сущности в пуле не считаются. Контейнер зовёт его, прежде чем принять запись, и отказывает, если владелец нашёлся. Сущность, которая отдаёт запись сама, передают в except, а после передачи ставят ей новую запись: entity.SetInstance(entity.CreateInstance()).

Изменения значений сущность узнаёт из события записи, а не из каждого места, которое их пишет:

public void SetInstance(EntityInstanceData data)
{
    if (Instance != null)
        Instance.Changed -= RecalculateStats;

    Instance = data;
    Instance.Changed += RecalculateStats;

    RecalculateStats();
}

Отписываются там же, где от остальных событий сущности, — в UnRegisterEventsOnDestroy(). Подписчики не сохраняются и в Clone() / Duplicate() не переходят.

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

Запись сериализуется в ProjectData как есть. Поддерживаются long, float, bool, string и DateTime. IIdentifiable запись не реализует: конвертер сохранения пишет от таких объектов один Id.

EntityMetadata

Описание отвечает на пять вопросов: какого сущность вида, как называется, как выглядит, как переводится и какого качества. Вид (EntityType) лежит в самом ассете, поэтому Entity работает без кода.

Description — собранное описание живой сущности. Он же поддерживает переопределение: базовое описание берётся из ассета или определения, а поверх может лечь второе.

string title  = entity.Description.GetName();
Sprite icon   = entity.Description.GetIcon();
string text   = entity.Description.GetLocalization();
QualityType q = entity.Description.GetQuality();

Порядок разрешения для каждого поля: функция-override → Override-описание → Base-описание. Функции-override (NameOverride, SpriteOverride, …) позволяют подменить одно поле, не трогая остальные, — например, показать в UI имя конкретного бота.

EntityBase<TMetadata> собирает контейнер сам: базовым берёт свой ассет, переопределением — IEntityMetadataProvider, найденный на объекте.

У RuntimeEntityBase базовым описанием служит сама сущность: имя, иконка и перевод лежат полями прямо в ней, отдельного ассета не нужно. Переопределение она ищет так же, но пропускает себя.

Готовая реализация переопределения — компонент EntityMetadataProvider: повесьте его на объект сущности и укажите ассет EntityMetadataBase. Так одному экземпляру можно дать собственное имя или иконку, не заводя отдельный тип сущности.

Переопределение по частям

Когда менять нужно одно поле, целый ассет описания заводить незачем — на объект сущности вешают компонент на нужную часть:

Компонент Что подменяет
EntitySpriteOverride Иконку (GetIcon)
EntityNameOverride Служебное имя (GetName)
EntityLocalizationOverride Подпись для игрока (GetLocalization)

Каждый реализует IEntityDescriptionOverride и в Apply записывает свою функцию-override. Локализация переопределяет источник перевода, а не готовую строку: подпись игроку отдают в SetLocalization, и LocalizationObserver перечитывает её по провайдеру при смене языка. Читать подпись для UI надо через Description.GetLocalizationProvider() — метаданные напрямую (GetMetadata()) переопределений экземпляра не знают. Значение читается в момент запроса, поэтому его можно менять и по ходу игры, а подпись переживает смену языка. EntityLocalizationOverride берёт источник первым заданным, как LocalizationObserver: ключ в базе локализации либо перевод прямо на объекте.

Компоненты собирает сама сущность в ApplyDescriptionOverrides() сразу после сборки описания. Ищутся они только на объекте сущности: у вложенной сущности своё описание.

Своё переопределение добавляют тем же способом — реализацией IEntityDescriptionOverride; у EntityDescription есть ещё SetQualityOverride.

В инспекторе

Под обычными полями сущности идёт раздел «Описание»: он показывает, откуда описание берётся, и разворачивает его прямо здесь — искать ассет по проекту не нужно. У каждого блока подписана область действия: правка общего описания меняет его всем сущностям сразу, а EntityMetadataProvider — только этому экземпляру. Если поле под описание есть, а ссылка пустая, рядом появляется кнопка «Создать описание».

Рисование живёт в EntityDescriptionSection. На один тип Unity должен быть один редактор: в проекте это PREntityInspector. Он наследует NaughtyInspector, без этого атрибуты вроде Button на сущностях не работают.

Собрать описания всего проекта, проверить их и увидеть, кто ими пользуется, можно в окне PRUnitySDK/Windows/Entity metadata — см. Editor.

Поиск сущности из компонента

Компонент, лежащий на дочернем объекте, находит свою сущность через EntityLink:

[RequireComponent(typeof(EntityLinkBase))]
public class HealthBar : PRMonoBehaviour
{
    private EntityLinkBase link;

    protected override void InitializationComponents()
    {
        link = GetComponent<EntityLinkBase>();
        base.InitializationComponents();
    }

    private void Refresh()
    {
        if (link.Entity == null)
            return;

        // link.Entity — сущность этого объекта
    }
}

EntityLink ищет сущность сам: сначала в родителях, затем на своём объекте. Ссылку можно задать и вручную в инспекторе — тогда автопоиск её не перезапишет. Типизированный вариант EntityLinkBase<T> даёт LinkedEntity нужного типа и TryGetEntity(out T).

Это же основной способ для систем SDK связать компонент с сущностью — так работает, например, EntityTimeScaleDriver, который берёт слой времени из связанной сущности.

Позиция сущности

Сущность бывает двух устройств: она либо сама корневой объект, либо контейнер, внутри которого лежит настоящий объект — так устроен игрок. EntityGameObject в обоих случаях указывает на то, что считается сущностью, а RootEntityObject — на корень иерархии.

Отсюда правило: позицию спрашивают и задают через сущность, а не через transform.

Член Что делает
Position Где сущность на самом деле — позиция EntityGameObject
Rotation Поворот EntityGameObject
SetPosition(position) Ставит сущность в точку
SetPositionAndRotation(position, rotation) То же плюс разворот
entity.SetPosition(spawnPoint);   // в точке окажется сущность, переедет вся иерархия

Двигается при этом корень — на разницу между тем, где сущность сейчас, и тем, где она должна оказаться:

RootEntityObject.transform.position += position - Position;

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

В SetPositionAndRotation сначала применяется поворот, и смещение считается уже после него.

Этим пользуются пул (ShowEntity досылает выданную сущность на место), спавнеры, HealthComponent.Revive и PlayerBase.Teleport. Свой код, который спавнит или переносит сущность, должен идти тем же путём.

Реестры

EntityTracker хранит все сущности, включая игроков. PlayerTracker остаётся отдельным реестром игроков: он ведёт Player ID, локальные слоты, счётчики и события входа/выхода.

Реестр Кто туда попадает Доступ
EntityTracker Все зарегистрированные сущности, включая игроков PRUnitySDK.Trackers.Entities
PlayerTracker Игроки (PlayerBase и наследники) PRUnitySDK.Trackers.Players

Игроки регистрируются через PlayerTracker.Register(). После проверки доступности локального слота он добавляет игрока в общий реестр. Entity ID выдаётся один раз общим трекером; если сущность уже зарегистрирована там, её ID сохраняется. Player ID назначается отдельно. Уход игрока через любой из двух реестров удаляет его из обоих и освобождает локальный слот.

EntityTracker.Clear() уничтожает также игроков и снимает их с PlayerTracker. PlayerTracker.Clear() уничтожает только игроков и удаляет их из общего реестра. Последовательная очистка обоих реестров не уничтожает игрока повторно.

Когда происходит регистрация

Момент Что происходит
Awake / InitializationComponents Регистрации ещё нет, Id == 0
Start Регистрация, выдача Id, сущность в реестре
Уход в пул Регистрация сохраняется, меняется InPool
Возврат из пула Повторной регистрации нет, Id остаётся прежним
OnDestroy Снятие с регистрации через UnRegisterEventsOnDestroy()

Идентификаторы

EntityIdGenerator выдаёт сквозной Entity ID. Освобождённые Entity ID не переиспользуются. Player ID переиспользуются, а локальным игрокам зарезервированы значения LocalPlayerOneId и LocalPlayerTwoId.

Поиск и подсчёт

var tracker = PRUnitySDK.Trackers.Entities;

var all = tracker.GetEntities();
var visiblePlayers = tracker.GetEntities(
    EntityTypeEnumerations.Player, EntitySearchFlags.Visible);
var living = tracker.GetEntities(
    EntitySearchFlags.Visible | EntitySearchFlags.Alive);
var ofMetadata = tracker.GetEntities(metadata, EntitySearchFlags.NotInPool);
var ofDefinition = tracker.GetEntities(definition, EntitySearchFlags.Visible);
var containers = tracker.GetEntities<ContainerEntityBase>(EntitySearchFlags.Visible);

// Можно одновременно ограничить вид и описание.
var specific = tracker.GetEntities(entityType, EntitySearchFlags.Visible, metadata);
long count = tracker.GetEntitiesCount(metadata, EntitySearchFlags.Visible);

EntityType сравнивается как Enumeration; CLR-тип в generic-поиске учитывает наследников и реализации интерфейса. Метаданные сравниваются по ссылке с Description.Base, Description.Override и исходным EntityBase<EntityMetadata>.Metadata. Поэтому сущность с EntityDefinition<T> находится как по её конкретному Definition, так и по общему ассету EntityMetadata. Definition уже реализует IEntityMetadata и передаётся в тот же метод поиска. Переопределение иконки или имени не исключает сущность из поиска по исходному ассету. Два разных ассета с одинаковым именем не совпадают.

Флаг Условие
None Все существующие зарегистрированные сущности, включая скрытые и пул
OnScene Существующее свойство IEntity.OnScene; у EntityBase это активность иерархии
InPool / NotInPool Объект в пуле / вне пула; NotInPool включает скрытые
Visible OnScene && !InPool, без проверки камеры
NotVisible Обратное Visible, включая отключённые объекты и пул
Hide Неактивен, вне пула, настроен Hide
HideWire Неактивен, вне пула, настроен HideWire
HideWirePolygons Неактивен, вне пула, настроен HideWirePolygons
Hidden Неактивен, вне пула, настроен любой из трёх Hide-режимов
Alive / Dead IHealthProvider.Health.IsAlive() возвращает true / false

Все условия объединяются через «и», кроме Hide-режимов: их маска выбирает любой указанный режим. Например, HideWire | HideWirePolygons | Dead найдёт мёртвые сущности, неактивные вне пула с одним из двух настроенных Hide-режимов. Visible | Hidden или Alive | Dead вернут пустой список.

Hide, HideWire и HideWirePolygons выбирают неактивные сущности вне пула по настроенному действию EntityBase.DisposeAction. Причину отключения поиск не отслеживает: отключённый вручную объект с такой настройкой тоже подходит под соответствующий Hide-флаг. Восстановленная активная сущность и объект в пуле не подходят.

Alive и Dead требуют IHealthProvider у самой сущности и существующий HealthComponent в его свойстве Health.Объекты без здоровья не подходят ни под один из этих двух флагов. Уничтоженные Unity-объекты исключаются из любого поиска.

GetEntities() возвращает новый список; GetEntitiesCount(...) с фильтром считает без создания списка. Остаются доступны GetExact*Count, GetInherited*Count и статистика RegisteredEntity. GetEntitiesCount() без аргументов по-прежнему возвращает число регистраций; для подсчёта только существующих объектов передайте EntitySearchFlags.None.

EditMode-тесты поиска и синхронизации реестров находятся в EntityManager/Editor/EntityTrackerSearchTests.cs и включаются символом PRSDK_TESTS.

Уничтожение и пул

entity.DestroyEntity();                                            // по настройке компонента
entity.DestroyEntity(new EntityDestroyOptions { FullDestroy = true }); // безусловно

Поведение задаёт поле EntityDisposeAction в инспекторе:

  • Destroy — объект уничтожается;
  • HideInPool — объект возвращается в пул;
  • Hide — объект выключается и остаётся на сцене.
  • HideWire — объект выключается, а его место занимает каркасная визуальная копия без коллайдеров и игровых компонентов.

Для HideWire копируются видимые MeshRenderer и SkinnedMeshRenderer. У читаемых мешей показываются рёбра треугольников. Для мешей без Read/Write используется сетка на поверхности. Копия повторяет иерархию и трансформации рендереров на момент уничтожения; анимация и игровая логика на ней не выполняются.

Размер каркаса задаёт PRUnitySDK.Settings.Entity.WireScale. Original (по умолчанию) — размер, с которым сущность появилась: блок, раздутый эффектом попадания в момент смерти, оставит каркас обычного размера. AtDeath — размер на момент смерти, со всем, что на него повлияло. Настройка касается корня сущности (EntityGameObject), дочерние объекты копируются как есть.

FullDestroy = true игнорирует эту настройку и уничтожает объект даже из пула. Если выбран HideInPool, но объект создан не через пул, SDK напишет предупреждение и уничтожит его, утечки не будет.

Объект, поднятый из пула, повторно проходит InitializeEntity(): при первом создании инициализация идёт обычным путём, при переиспользовании — через PoolBehaviour. Всё, что должно сбрасываться между использованиями, кладите именно туда, а не в Awake.

Возврат спрятанных сущностей

Спрятанные (Hide) сущности возвращает на сцену общее событие:

EntityEvents.RestoreHideEntities();

Его получают все сущности сразу; каждая проверяет свою настройку и включается, если была спрятана через Hide или HideWire. В режиме HideWire перед включением удаляется визуальная копия. Выключенный объект событие тоже получает.

Вернувшаяся сущность повторно проходит InitializeEntity() — так же, как поднятая из пула. Отдельного метода и настройки под это нет.

Восстанавливается всё и всегда

Восстановление не спрашивает, есть ли что чинить. Сущность, оставшаяся на сцене, проходит ровно то же, что и вернувшаяся: место, поворот, размер, инициализация.

Что возвращается Откуда берётся
Позиция и поворот запомнены при появлении сущности
Размер localScale у EntityGameObject на тот же момент
Всё остальное InitializeEntity()

Место запоминается до первой инициализации: она уже может сущность подвинуть. Размер берётся у EntityGameObject, а не у корня иерархии.

InitializeEntity() выполняется и у нетронутой сущности. Для LeafEntity это значит новую случайную текстуру и новый поворот при каждом восстановлении.

Скорости Rigidbody восстановление не трогает.

Характеристики

Базовые значения хранит EntityStatsBase — ScriptableObject со словарём «стат → число»:

public class PlayerStats : EntityStatsBase<PlayerStatsEnumeration> { }

Итоговое значение считает EntityStatsUtils, применяя три слоя по порядку:

EntityStatsBase → персональные модификаторы → GameRules → результат

Персональные модификаторы описывает StatModifier, а собирает их с дочерних объектов сущности StatModifierCollector — компонент, который вешается рядом с EntityLinkBase и обновляется при смене экипировки. Передать модификаторы можно им либо делегатом Func<Enumeration, float, float> — тогда стат считается без компонента на сцене, например в редакторе или в тестах.

// Обычный путь: модификаторы даёт сборщик на префабе
int jumps = EntityStatsUtils.GetStatInt(
    PlayerStatsEnumeration.JumpCount, Core.Stats, statCollector, 1);

// Без сборщика: модификатор — это преобразование «ключ и базовое значение → итог»
float speed = EntityStatsUtils.GetStat(
    PlayerStatsEnumeration.WalkSpeed, Core.Stats, (stat, value) => value * 1.2f);

GetStatLong() дополнительно защищает от выхода за границы типа и от NaN. Округление всегда происходит после всех модификаторов и правил, а не в промежутке.

Важно: GetStatInt округляет к ближайшему целому. Для дробных величин (время, доли секунды, множители) используйте GetStat — иначе значение вроде 0.15 превратится в 0.

Устройство модификаторов, сборщик и известные ограничения — в EntityStats.

Игроки

IPlayer расширяет IEntity очками, убийствами, смертями, командой, типом управления (Human / AI / NPC) и характеристиками. PlayerBase добавляет события смены ника, инициализации, изменения очков и учёт атакующего.

PlayerBase реализует IHealthProvider. При инициализации он запоминает существующий HealthComponent на своём объекте или добавляет его, если в префабе компонента нет. Для локальных игроков и ботов устанавливаются IsImmortal = true и IsBlockDamage = true: атаки блокируются и здоровье не расходуется. Обычный KillByGame() возвращает Blocked. DeadZone вызывает Kill(this, force: true), поэтому зона смерти продолжает убивать игроков и запускать возрождение. Эти признаки сохраняются после Revive.

PlayerTracker ведёт отдельный учёт: сколько всего игроков, сколько людей, сколько локальных. Локальным игрокам выделены фиксированные идентификаторы (LocalPlayerOneId, LocalPlayerTwoId), а MaxLocalPlayer зависит от устройства — на десктопе допускается двое, на мобильном один.

Команды — DefaultTeam, RedTeam, BlueTeam с фиксированными GUID из TeamGuids.

Контейнеры

ContainerEntityBase — сущность, которую игрок подбирает касанием:

Тип Что выдаёт
ResourceContainer Ресурс в заданном количестве — через IPickupResource
ActionContainer Выполняет действие IconActionBase

Кто может подобрать, задаётся флагами PlayerTypeFlags — например, чтобы AI не собирал монеты. После успешного подбора контейнер уничтожает себя по своим правилам пула.

Подбор идёт через PR-хуки (PROnTriggerEnter, PROnCollisionEnter), поэтому во время логической паузы не срабатывает. Касание при открытом окне не теряется: триггер сработает сразу после снятия паузы.

Сигнал готовности

ReadySignal решает задачу «подписаться на событие, которое могло уже произойти»:

PRUnitySDK.ReadySignal.SubscribeOnReady(() =>
{
    // Выполнится сразу, если SDK уже готов, иначе — в момент готовности.
});

Подписчик, пришедший после SetReady(), вызывается немедленно и синхронно. Дубликаты подписок отсекаются, после срабатывания список очищается, а исключение в одном callback'е не мешает остальным. Это правильный способ дождаться готовности вместо проверок в Update.

Сущность без объекта

GameEventEntity — псевдосущность для урона и событий, у которых нет источника на сцене (падение с высоты, утопление, зона). У неё Id == -1, она всегда «на сцене» и не попадает в пул. Нужна там, где API требует IEntity, а виновника не существует.

Ограничения

RuntimeEntityBase использует собственные метаданные как основу и ищет внешний IEntityMetadataProvider среди остальных компонентов объекта. При отсутствии метаданных EntityDescription.GetQuality() возвращает QualityType.Common.

OnScene учитывает активность всей иерархии. Успешный подбор ресурса проверяет isTaken. FullDestroy снимает объект с учёта пула. Повторный возврат не добавляет объект в очередь второй раз.

  • Переопределение описания задаётся только в инспекторе. EntityMetadataProvider хранит ссылку на ассет, а EntityDescription собирается один раз при инициализации сущности: подменить описание в рантайме через SetEntityMetadata() можно лишь до её регистрации.
  • Сущность без описания попадает в вид Unknown. EntityBase<TMetadata> берёт вид из ассета, и незаполненная ссылка даёт не ошибку, а EntityTypeEnumerations.Unknown: Раздел «Описание» в инспекторе сущности предупреждает об этом сразу.
  • EntityManager — пустой класс. Наследник SingletonProviderBase, не содержит ничего и никем не используется; фактический реестр — EntityTracker, доступный через EntityService.Instance и PRUnitySDK.Trackers.Entities.
  • Id недоступен до Start(). Регистрация идёт из Start(), поэтому в Awake и InitializationComponents идентификатор равен нулю.
  • Регистрация не снимается при уходе в пул. Скрытая в пуле сущность остаётся в реестре; различать её нужно по InPool, а не по факту присутствия в списке.
  • Снимки коллекций аллоцируют. Entities, Players и TrackerBase.Elements каждый раз возвращают новый List через ToList(). В Update такие обращения дают постоянный мусор — кешируйте результат или считайте через GetExact*Count.
  • Поиск стата линейный. EntityStatsBase<TEnum>.TryGet перебирает весь словарь и на каждой итерации вызывает ToEnumeration(). Для статов, читаемых каждый кадр, это заметно.
  • Логика DestroyEntity непрозрачна. Четыре ветки со сложными условиями и throw new NotImplementedException() в конце: добавление нового значения EntityDisposeAction без своей ветки приведёт к исключению в рантайме.
  • IEntity.gameObject объявлен со строчной буквы, как Unity-свойство. Для не-Unity реализаций (GameEventEntity) объект создаётся фабрикой по требованию.

Смотрите также

  • Trackers — общий контракт реестров, камеры, окна и уведомители
  • PRMonoBehaviour — базовый lifecycle, пауза, физические хуки
  • PropertyContainer — персональные модификаторы характеристик
  • GameRules — глобальные ограничения статов
  • PRTimeScale — слои времени и EntityTimeScaleDriver
  • Enumeration — ключи типов, статов и слоёв