Дьявол против четверых: как я реализую асимметричный хоррор на Unity и Netcode for GameObjects

Привет! Я работаю над своим новым проектом — асимметричным мультиплеерным хоррором. Суть простая, но дьявольски сложная в реализации: 4 игрока-человека пытаются выжить, а 5-й берет на себя роль Дьявола. Полная темнота, взаимодействие с предметами через физику, ритуалы, и постоянный страх.

Когда я начинал этот проект, я понимал: сетевой код — это та часть, на которой сыпятся большинство инди-разработчиков. Я выбрал Netcode for GameObjects (NGO) от самой Unity. Сегодня хочу рассказать, с какими сложностями я столкнулся, как я настраиваю взаимодействие игроков с миром и почему сетевая архитектура — это не про код, а про дисциплину.

Почему я выбрал NGO?

Выбор был между Mirror, Photon и официальным NGO. Я решил остановиться на официальном решении, так как оно развивается вместе с движком. Да, у него есть свои «детские болезни», но документация становится лучше, а интеграция с пакетами Unity — бесшовнее.

Основная боль любого мультиплеера — авторитетность сервера. Я сразу поставил себе правило: клиент никогда не должен верить себе. Все важные решения (подобрал предмет, нанес урон, активировал ритуал) проходят через сервер.

Главная механика: «Живые» руки и физическое взаимодействие

В моем хорроре взаимодействие — это не просто OnTriggerEnter + нажатие кнопки «E». Я хотел, чтобы персонажи физически брали предметы: рука «прилипает» к объекту.

Здесь я столкнулся с первой проблемой синхронизации. Когда игрок берет предмет, он должен стать его «владельцем» в сетевом смысле, иначе физика на клиенте и сервере разойдется, и предмет будет висеть в воздухе или дергаться.

Как я это реализовал (на примере подбора предмета)

В Netcode for GameObjects есть понятие NetworkObject. Чтобы игрок мог взаимодействовать с предметом, он должен запросить владение этим объектом.

Вот упрощенный пример того, как это выглядит у меня в коде:

using Unity.Netcode; using UnityEngine; public class PickableObject : NetworkBehaviour { // Метод, который вызывает игрок при попытке взять предмет [ServerRpc(RequireOwnership = false)] public void RequestTakeItemServerRpc(ulong clientId) { // Проверяем, свободен ли объект if (IsOwnedByServer) { // Передаем владение игроку, который вызвал метод GetComponent<NetworkObject>().ChangeOwnership(clientId); // Отправляем всем клиентам сигнал, что объект взят UpdateItemStateClientRpc(true, clientId); } } [ClientRpc] private void UpdateItemStateClientRpc(bool isTaken, ulong ownerId) { // Логика визуализации: рука "приклеивается" к предмету // Тут мы подсвечиваем или меняем родителя на клиенте Debug.Log(quot;Предмет взят игроком {ownerId}"); } }

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

Проблема «темноты» и освещения

Игра построена на том, что игроки почти ничего не видят. Источник света (фонарь, магический камень) — это критически важный объект.

Я решил не синхронизировать каждый кадр источника света через NetworkTransform, это убьет трафик. Вместо этого:

  1. Сервер знает, в каком состоянии свет (вкл/выкл) и у кого он в руках.
  2. Клиент только получает состояние (через NetworkVariable) и локально включает/выключает свой компонент Light.

Это спасает от лагов. Сетевой код должен передавать только состояние, а не каждый чих анимации.

Уроки, которые я извлек (и на которых набил шишки)

1. Не все нужно синхронизировать

Поначалу я пытался запихнуть в NetworkTransform абсолютно всё: от качания травы до поворота головы персонажа. Итог — игра начала «лагать» уже при двух игроках.

Мой подход сейчас:

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

2. RPC vs NetworkVariables

Я долго путался, что использовать.

  • NetworkVariables — использую для состояний, которые меняются редко (уровень здоровья, статус игрока: жив/мертв, открыта ли дверь). Это надежнее, так как новые игроки, подключившиеся к сессии, сразу получают актуальное состояние.
  • RPC (Remote Procedure Calls) — использую для событий, которые случаются один раз (выстрел, скример, поднятие предмета, проигрывание звука).

3. Режим «Дьявола»

Это самая сложная часть архитектуры. Дьявол обладает другими правами доступа, чем игроки. Он видит в темноте, он может расставлять капканы. Пришлось создать отдельный PlayerController для Дьявола и обычный для выживших. Они оба наследуются от базового класса, но логика взаимодействия с NetworkObject у них разная. Не пытайтесь написать один скрипт "на все случаи жизни" — это прямой путь к "спагетти-коду", в котором вы сами запутаетесь через неделю.

Что дальше?

Сейчас проект на этапе сборки базы. Я закончил с «прилипанием» рук и базовым освещением. Впереди самая вкусная часть — фаза подготовки, когда Дьявол видит всю карту, а игроки — лишь узкий конус света от фонарей.

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

2
Начать дискуссию