Перейти к содержанию

NetworkOwnershipVisibility — спавн и инициализация

Пошаговый порядок спавна / ownership / видимости в PurrNet, как из этого складываются реплики и конкретные флоу проекта (старт хоста, подключение клиента, дисконнект/реконнект). Про сам компонент и почему выбран такой подход — в README.md.

Порядок спавна в PurrNet (проверено по коду пакета)

  • HierarchyV2.InternalSpawn считает видимость и флашит spawn-пакет ДО передачи ownership (HierarchyV2.cs:1575-1584), затем AutoAssignOwnership (:1586).
  • PlayerSpawner.OnPlayerLoadedScene инстанцирует префаб и зовёт GiveOwnership(player) в одном синхронном колбэке (PlayerSpawner.cs:171-188).
  • GiveOwnership проставляет серверный owner и фаерит OnOwnerChanged синхронно (GlobalOwnershipModule.cs:533,578-583) — не откладывается.
  • Видимость PurrNet пересчитывает только на spawn, загрузке сцены и явном EvaluateVisibility (периодического пересчёта нет). Owner видит свою приватку через short-circuit owner == player в VisilityV2.Evaluate — PurrNet добавляет владельца в observers отдельно от правила.

Как выглядят реплики (наличие vs активность)

Реплики собираются из двух ортогональных осей — их нельзя путать:

  1. Наличие/отсутствие объекта = видимость (observers). Закулленную NoVisibility identity peer вообще не получает — её GameObject у него не создаётся (не «выключен», а отсутствует). Кому реплицировать — решают observers.
  2. Активность объекта = тоглер (activeSelf/enabled). Актуально только там, где объект присутствует. activeSelf приезжает из spawn-прототипа (копия серверной иерархии), затем тоглер добивает: на контроллере — вкл, иначе — выкл (force-refresh на OnSpawned / проход в LateUpdate).

Реплика игрока X у разных наблюдателей (Eye несёт PlayerInteractor и под тоглером; Inventory несёт NetworkInventory и под тоглером не ходит):

Наблюдатель Eye / PlayerInteractor NetworkInventory Avatar / root
Сам владелец X есть, активен (контроллер) есть, активен, синкается есть
Хост / сервер есть, но неактивен (тоглер: не контроллер) есть, активен, синкается (сервер пишет) есть
Другой клиент отсутствует (culled NoVisibility) отсутствует (culled) есть

Почему так:

  • Отсутствие у чужихNoVisibility.CanSee=false, identity к ним не едет, GameObject не создаётся.
  • Наличие у хоста — сервер всегда в observers (пишет транзакции, шлёт RPC), поэтому приватка к нему реплицируется. Наличие у владельца — через short-circuit owner == player.
  • Eye неактивен, а Inventory активен на хосте — Eye под тоглером (камера/ввод), и на не-контроллере тоглер гасит его activeSelf; Inventory под тоглером не ходит, поэтому остаётся активным. Синк инвентаря при этом определяется видимостью, а не активностью, — сервер в него пишет независимо.

NB (когда возникает spawn→cull). «Полный префаб, затем despawn приватки» бывает только у наблюдателя, который был observer'ом в момент спавна нового игрока и при этом не сервер и не owner. Направление важно: spawn→cull ловят уже подключённые удалённые клиенты на вновь входящего, а не наоборот — поздний клиент чужую приватку получает уже отфильтрованной, одним пакетом (не шлётся вовсе, а не куллится частями — см. «Подключается удалённый клиент», шаг 2). Синхронность входов не нужна: при обычных последовательных коннектах хватает, чтобы к моменту спавна новичка в сцене уже сидел другой удалённый клиент. Значит нужны ≥2 удалённых клиента: при «хост + 1 клиент» единственный старый наблюдатель новичка — сервер, его не куллит → spawn→cull (и завязанный на него инвариант «инвентарь пуст на спавне» из README.md) не воспроизводится. Оговорка: на локальном UDP номера могут перевернуться (2-й подключившийся станет 001), и тогда та же ветка иногда ловится и в два инстанса.

Флоу в проекте (по шагам, проверено по коду)

Префаб игрока: рут (ECM2-контроллер + NetworkTransform + NetworkOwnershipToggle + NetworkOwnershipVisibility); дети — Eye (PlayerInteractor), Inventory (NetworkInventory), Avatar (публичный). Тоглер → камера/глаз/ввод; визибилити-targets → PlayerInteractor + NetworkInventory. Конфиг: DefaultOwner = None, applyInLateUpdate on у обоих, despawnIfOwnerDisconnects = 0.

Номера — для наглядности: 001 — хост (сервер + свой локальный клиент), 002 — удалённый клиент.

Про ownership важно различать два механизма: AutoAssignOwnership (авто, внутри InternalSpawn, :1586, отдал бы объект спавнеру = хосту) и явный GiveOwnership(player) из спавнера (PlayerSpawner.cs:188, player — тот, кто загрузил сцену). None выключает только авто-шаг; явный вызов идёт всегда.

Старт хоста (игрок 001)

Хост — один процесс (сервер + локальный клиент 001), поэтому меток по инстансам тут нет: всё происходит в нём же.

  1. Хост грузит сцену → ScenePlayersModule.onPlayerLoadedScene(001).
  2. PlayerSpawnerInstantiateInternalSpawn: спавн AlwaysVisible; авто-AutoAssignOwnership пропущен (None) — транзиентного owner нет.
  3. Явный GiveOwnership(001) (PlayerSpawner:188, тут player = сам хост) → серверный owner=001, синхронный OnOwnerChanged(null→001) одним шагом (это финальный owner, а не транзиент). Тоглер и визибилити ставят dirty (applyInLateUpdate), OnSpawned — тоже.
  4. LateUpdate: тоглер isController = isOwner(001) = trueSetup(true), камера/глаз/ввод 001 включены. Визибилити Privatize(owner=001)NoVisibility на приватку, наблюдатель только 001 (short-circuit) — куллить некого (002 ещё нет).

Подключается удалённый клиент (игрок 002)

Здесь два разных процесса, и шаги идут между ними: [сервер] (у хоста — тот же процесс, что и клиент 001) и [инстанс 002] — отдельный клиент. Почти всё решается на сервере; клиент только шлёт запрос и потом принимает результат. Помечаю процесс у каждого шага.

  1. [инстанс 002 → сервер] 002 подключается, грузит сцену и шлёт об этом сообщение серверу.
  2. [сервер] HierarchyV2.OnPlayerLoadedScene(002) (:1253, только _asServer) пересчитывает видимость уже заспавненного игрока 001 под 002: root + Avatar AlwaysVisible → шлёт; приватка 001 под NoVisibilityCanSee(002)=falseне шлёт (решение принимает сервер, у 002 её просто не появится).
  3. [сервер] PlayerSpawner.OnPlayerLoadedScene(002) спавнит игрока 002: AlwaysVisible пакет всем в сцене (001 + 002), авто-присвоение пропущено (None), явный GiveOwnership(002)OnOwnerChanged(null→002) → dirty.
  4. [сервер] LateUpdate: тоглер игрока 002 isController=false (owner=002, а локальный игрок сервера — 001) → Setup(false) → в серверной иерархии глаз/камера 002 выключены. Визибилити Privatize(owner=002) → наблюдатели 002 + сервер.
  5. [инстанс 002] принимает от сервера пакеты: свой префаб — с приваткой (owner short-circuit); реплику 001 — без приватки. Локальные тоглеры в этом процессе: свой игрок isController=true → камера/глаз/ввод включены; реплика 001 isController=false → глаз 001 выключен.

Дисконнект + реконнект игрока 002 (проверено — работает)

  1. Обрыв 002: [сервер] OnOwnerDisconnect (:401) — ShouldDespawnOnOwnerDisconnect()=false (despawnIfOwnerDisconnects=0) → игрок 002 не деспавнится, ownership и правило NoVisibility сохраняются. Реплика 002 (root+Avatar) остаётся у остальных.
  2. Реконнект 002 — снова два процесса, но вся логика [сервер], 002 лишь заходит в сцену и принимает результат:
  3. PlayerSpawner.OnPlayerLoadedScene (:158-162): despawn-off И PlayerOwnsSomething(002)=true → ре-спавна нет, старый объект переиспользуется.
  4. GlobalOwnershipModule.OnPlayerLoadedScene (:291-298): снапшот овнершипа + OnOwnerReconnected → тоглер RequestRefresh() восстанавливает Setup(isController).
  5. HierarchyV2.OnPlayerLoadedScene (:1253): видимость пересчитывается под 002 → его приватка снова проходит (owner short-circuit), сервер ре-шлёт её → [инстанс 002] снова видит инвентарь; приватка 001 по-прежнему CanSee(002)=false.

Почему корректно: объект, ownership и NoVisibility переживают обрыв; вход в сцену пересчитывает видимость (owner возвращается, чужим не течёт), а OnOwnerReconnected возвращает состояние тоглера.