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-circuitowner == playerвVisilityV2.Evaluate— PurrNet добавляет владельца в observers отдельно от правила.
Как выглядят реплики (наличие vs активность)
Реплики собираются из двух ортогональных осей — их нельзя путать:
- Наличие/отсутствие объекта = видимость (observers). Закулленную
NoVisibilityidentity peer вообще не получает — её GameObject у него не создаётся (не «выключен», а отсутствует). Кому реплицировать — решают observers. - Активность объекта = тоглер (
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), поэтому меток по инстансам тут нет: всё происходит в нём же.
- Хост грузит сцену →
ScenePlayersModule.onPlayerLoadedScene(001). PlayerSpawner→Instantiate→InternalSpawn: спавнAlwaysVisible; авто-AutoAssignOwnershipпропущен (None) — транзиентного owner нет.- Явный
GiveOwnership(001)(PlayerSpawner:188, тутplayer= сам хост) → серверный owner=001, синхронныйOnOwnerChanged(null→001)одним шагом (это финальный owner, а не транзиент). Тоглер и визибилити ставят dirty (applyInLateUpdate),OnSpawned— тоже. LateUpdate: тоглерisController = isOwner(001) = true→Setup(true), камера/глаз/ввод 001 включены. ВизибилитиPrivatize(owner=001)→NoVisibilityна приватку, наблюдатель только 001 (short-circuit) — куллить некого (002 ещё нет).
Подключается удалённый клиент (игрок 002)
Здесь два разных процесса, и шаги идут между ними: [сервер] (у хоста — тот же процесс, что и клиент 001) и [инстанс 002] — отдельный клиент. Почти всё решается на сервере; клиент только шлёт запрос и потом принимает результат. Помечаю процесс у каждого шага.
- [инстанс 002 → сервер] 002 подключается, грузит сцену и шлёт об этом сообщение серверу.
- [сервер]
HierarchyV2.OnPlayerLoadedScene(002)(:1253, только_asServer) пересчитывает видимость уже заспавненного игрока 001 под 002: root + AvatarAlwaysVisible→ шлёт; приватка 001 подNoVisibility→CanSee(002)=false→ не шлёт (решение принимает сервер, у 002 её просто не появится). - [сервер]
PlayerSpawner.OnPlayerLoadedScene(002)спавнит игрока 002:AlwaysVisibleпакет всем в сцене (001 + 002), авто-присвоение пропущено (None), явныйGiveOwnership(002)→OnOwnerChanged(null→002)→ dirty. - [сервер]
LateUpdate: тоглер игрока 002isController=false(owner=002, а локальный игрок сервера — 001) →Setup(false)→ в серверной иерархии глаз/камера 002 выключены. ВизибилитиPrivatize(owner=002)→ наблюдатели 002 + сервер. - [инстанс 002] принимает от сервера пакеты: свой префаб — с приваткой (owner short-circuit);
реплику 001 — без приватки. Локальные тоглеры в этом процессе: свой игрок
isController=true→ камера/глаз/ввод включены; реплика 001isController=false→ глаз 001 выключен.
Дисконнект + реконнект игрока 002 (проверено — работает)
- Обрыв 002: [сервер]
OnOwnerDisconnect(:401) —ShouldDespawnOnOwnerDisconnect()=false(despawnIfOwnerDisconnects=0) → игрок 002 не деспавнится, ownership и правилоNoVisibilityсохраняются. Реплика 002 (root+Avatar) остаётся у остальных. - Реконнект 002 — снова два процесса, но вся логика [сервер], 002 лишь заходит в сцену и принимает результат:
PlayerSpawner.OnPlayerLoadedScene(:158-162): despawn-off ИPlayerOwnsSomething(002)=true → ре-спавна нет, старый объект переиспользуется.GlobalOwnershipModule.OnPlayerLoadedScene(:291-298): снапшот овнершипа +OnOwnerReconnected→ тоглерRequestRefresh()восстанавливаетSetup(isController).HierarchyV2.OnPlayerLoadedScene(:1253): видимость пересчитывается под 002 → его приватка снова проходит (owner short-circuit), сервер ре-шлёт её → [инстанс 002] снова видит инвентарь; приватка 001 по-прежнемуCanSee(002)=false.
Почему корректно: объект, ownership и NoVisibility переживают обрыв; вход в сцену пересчитывает
видимость (owner возвращается, чужим не течёт), а OnOwnerReconnected возвращает состояние тоглера.