Если подумать, то игр, действие которых разворачивается преимущественно или целиком под водой не так много. Да, я изучал подводный город Восторг в BioShock, управлял героями в Barotrauma и исследовал бесконечный океан в ABZÛ. Но ни одна из них не подарила мне столько эмоций, сколько Subnautica. Тот самый случай, когда садишься днем поиграть на час, а потом обнаруживаешь, что в реальном мире уже глубокая ночь.

Сейчас в ранний доступ вышла вторая часть, и для меня это стал отличным поводом перепройти оригинальную игру с нуля. Ныряешь, и главным прибором становится счетчик кислорода. Он позволяет оценить, стоит ли двигаться дальше или пора возвращаться. А прозвучавшее синтетическим голосом слово «oxygen» говорит о том, что времени на принятие решения уже не осталось. В какой-то момент мне пришла мысль: было бы здорово превратить счетчик в реальное устройство. Это и положило начало небольшому, но интересному DIY-проекту.

Окно консоли при запуске
Окно консоли при запуске

Действие игры Subnautica (и спин-оффа Below Zero) разворачивается на планете, поверхность которой почти полностью покрыта океаном. Атмосфера схожа с земной — кислород и азот, а большая часть экосистемы находится под водой. В самом начале игры космический корабль «Аврора» терпит бедствие и падает на эту планету.

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

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

Тот самый счетчик
Тот самый счетчик

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

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

Первый взгляд

Буквально сразу стало ясно — Subnautica чуть ли не идеальный кандидат для того, чтобы спокойно реализовать задуманное. Разработчики из Unknown Worlds Entertainment изначально обозначили свою дружелюбную позицию, разрешив создавать модификации. Что неудивительно — ведь сама студия фактически была основана моддерами, к тому же собравшими вокруг себя живое сообщество. Так что у игры есть фреймворк Nautilus и загрузчик BepInEx. Их я и хотел бы попробовать в первую очередь.

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

Предположим, нужные числа получили. Далее их потребуется передать на устройство вывода. Оригинальный индикатор круглый — было бы здорово найти экран этой же формы для аутентичности. И искать долго не пришлось! Перед вами GC9A01:

Модуль GC9A01. Источник
Модуль GC9A01. Источник

Это простой, но весьма удобный дисплей с разрешением 240×240 точек, управляемый по SPI-шине. Он совместим с Arduino, ESP32 и одноплатниками вроде Raspberry Pi. Его габариты позволяют сделать красивый круглый корпус в соответствующем стиле.

Мозгами проекта станет ESP32 — самый простой и бюджетный вариант. Данные можно отправлять в виде broadcast-пакетов по протоколу UDP — тогда не придется прописывать для ESP фиксированный IP-адрес, который может меняться. Достаточно указать нужный порт.

Извлечение данных

Как я уже говорил, есть фреймворк и загрузчик. Данные можно брать прямо в игровом объекте, поэтому чтобы до них добраться, надо написать небольшой плагин на C#. Его задача — раз в 100–200 мс читать значения:

float oxygen = Player.main.oxygenMgr.GetOxygenAvailable();

float capacity = Player.main.oxygenMgr.GetOxygenCapacity();

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

C:\Program Files (x86)\Steam\steamapps\common\Subnautica

На всякий случай запустил Steam и проверил, что доступных обновлений нет.

Установка BepInEx

Актуальный релиз Tobey's BepInEx Pack for Subnautica доступен в репозитории на GitHub. Я проверил, что игра не запущена, и распаковал архив прямо в директорию с ней. Затем требуется разово выполнить запуск, чтобы сгенерировать все нужные папки и файлы, причем исключительно через Steam Launcher, а не напрямую. В процессе мелькнуло окно BepInEx.

Внутри %GAMEDIR%\BepInEx появился файл LogOutput.log — хороший знак, все запустилось корректно. Теперь пора включить консоль, которая будет отображаться отдельным окном и появляться вместе с игрой. Для этого открываю  %GAMEDIR%\BepInEx\config\BepInEx.cfg, нахожу раздел [Logging.Console] и меняю значение по умолчанию false на true

Сохраняю файл и перезапускаю игру. Окно консоли успешно появилось — туда я и стану выводить извлекаемые значения счетчика кислорода.

Окружение разработчика

Чтобы скомпилировать плагин под рантайм игры (net472, Unity 2019.4) потребуется свежий .NET SDK. После установки проверил:

C:\> dotnet --version

10.0.301

Перед тем как писать плагин, нужно создать библиотеку классов (classlib). Обычное приложение (оконное или консольное) обычно компилируется в исполняемый файл с расширением .exe. У него есть точка входа — метод Main(). Когда ОС запускает приложение, выполнение начинается с этой точки, а программа становится хозяином процесса.

Библиотека классов компилируется в .dll и собственной точки входа не имеет. Это просто набор классов и методов, упакованный в файл. DLL живет только когда ее загружает какая-то программа и вызывает код. Приложение — хозяин процесса, библиотека — гость. Чтобы плагин заработал, ему нужно оказаться внутри процесса игры — здесь помогает загрузчик.

BepInEx при запуске сканирует директорию plugins и если находит там *.dll, то загружает их в память игры и создает соответствующий класс. Затем Unity вызывает его с помощью Update().

Написание кода

Я создал новый файл проекта OxygenTap.csproj (все будет лежать в C:\dev\OxygenTap) и описал его:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>

    <TargetFramework>net472</TargetFramework>

    <LangVersion>latest</LangVersion>

    <AssemblyName>OxygenTap</AssemblyName>

    <GameDir>C:\Program Files (x86)\Steam\steamapps\common\Subnautica</GameDir>

  </PropertyGroup>

  <ItemGroup>

    <PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies"

                      Version="1.0.3" PrivateAssets="all" />

  </ItemGroup>

  <ItemGroup>

    <Reference Include="BepInEx">

      <HintPath>$(GameDir)\BepInEx\core\BepInEx.dll</HintPath>

      <Private>false</Private>

    </Reference>

    <Reference Include="UnityEngine.CoreModule">      <HintPath>$(GameDir)\Subnautica_Data\Managed\UnityEngine.CoreModule.dll</HintPath>

      <Private>false</Private>

    </Reference>

    <Reference Include="Assembly-CSharp">

      <HintPath>$(GameDir)\Subnautica_Data\Managed\Assembly-CSharp.dll</HintPath>

      <Private>false</Private>

    </Reference>

  </ItemGroup>

</Project>

Обратите внимание на наличие Microsoft.NETFramework.ReferenceAssemblies — это позволит собрать проект под net472 без установки Developer Pack. И теперь сам тестовый плагин (файл Plugin.cs):

using BepInEx;

using UnityEngine;

[BepInPlugin("habr.oxygentap", "Oxygen Tap", "0.1.0")]

public class OxygenTap : BaseUnityPlugin

{

    private float _timer;

    private void Update()

    {

        _timer += Time.deltaTime;

        if (_timer < 0.25f) return;

        _timer = 0f;

        var player = Player.main;

        if (player == null || player.oxygenMgr == null) return;

        float o2  = player.oxygenMgr.GetOxygenAvailable();

        float cap = player.oxygenMgr.GetOxygenCapacity();

        Logger.LogInfo($"O2: {o2:F0}/{cap:F0}");

    }

}

Проверка Player.main == null очень важна, поскольку плагин запускается еще в главном меню, где по факту игрока пока не существует. Без нее в логе будет NullReferenceException.

Перешел в директорию проекта:

C:\> cd C:\dev\OxygenTap

и запустил компиляцию:

C:\> dotnet build -c Release

Restore complete (0.3s)

  OxygenTap net472 failed with 2 error(s) (0.3s)

    C:\dev\OxygenTap\Plugin.cs(4,2): error CS0012: The type 'MonoBehaviour' is defined in an assembly that is not referenced. You must add a reference to assembly 'UnityEngine, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'.

    C:\dev\OxygenTap\Plugin.cs(5,26): error CS0012: The type 'MonoBehaviour' is defined in an assembly that is not referenced. You must add a reference to assembly 'UnityEngine, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'. 

Первый блин комом. Несмотря на то, что тип MonoBehaviour живет в UnityEngine.CoreModule.dll, которую я подключил ранее в файле проекта, сам BepInEx собирался со ссылкой на старую сборку UnityEngine.dll. Пришлось подключить и ее:

    <Reference Include="UnityEngine">

        <HintPath>$(GameDir)\Subnautica_Data\Managed\UnityEngine.dll</HintPath>

        <Private>false</Private>

    </Reference>

Повторил компиляцию и ура — все собралось, а в C:\dev\OxygenTap\bin\Release\net472 появился плагин OxygenTap.dll. Осталось лишь закинуть этот файл в директорию с игрой: 

C:\> copy bin\Release\net472\OxygenTap.dll "C:\Program Files (x86)\Steam\steamapps\common\Subnautica\BepInEx\plugins\"

Проверяю так — запустил игру, загрузил сохраненную ранее сессию, нырнул и всплыл:

Данные от самодельного плагина в консоли
Данные от самодельного плагина в консоли

Данные в консоль поступают. Первый этап разработки завершен.

Отправка

Пришла пора определиться с форматом пакетов. Можно было бы пойти стандартным путем — JSON. Но это добавит лишние байты, зависимость от библиотеки и выделение памяти под документ. Лучше взять самый простой формат с разделителем — точкой с запятой:

O2;123.0;135.0

Так я смогу парсить данные, обходясь только стандартной библиотекой C и strtok:

strtok(buf, ";");                    // отрезает префикс "O2", двигает указатель

float o2  = atof(strtok(nullptr, ";"));  // "123.0" -> 123.0f

float cap = atof(strtok(nullptr, ";"));  // "135.0" -> 135.0f

Эти три строчки — все, что нужно. Парсер идет по буферу, меняет разделители на \0 и возвращает указатели на получившиеся куски. Копирований нет, аллокаций нет, населена роботами, буфер один. В многопоточном коде было бы дурным тоном хранить состояние в глобальной переменной, но тут простой однопоточный loop().

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

Каждый кадр отнимает по чуть-чуть, что позволяет дуге убывать плавно, а на счетчике отображается целочисленное значение. Плагин будет обновлять данные 4 раза в секунду. Если делать округление при отправке, дуга на дисплее будет шагать заметными скачками. А так появляется возможность более красивой анимации. Лишняя пара байт на число при 20-байтовых пакетах — это ничто, а польза будет очевидной.

Префикс O2 — страховка от «мусора», который может прилететь на порт. Если в будущем на устройство захочется вывести глубину или здоровье, будет удобно разделять данные по префиксу, а strcmp разберет их по первому токену. Формат не ломается, а старые версии прошивок просто проигнорируют незнакомые префиксы.

Сам код плагина сильно менять не придется.

using System.Net.Sockets;

using System.Text;

using BepInEx;

using UnityEngine;

[BepInPlugin("habr.oxygentap", "Oxygen Tap", "0.2.0")]

public class OxygenTap : BaseUnityPlugin

{

    private const int Port = 17777;

    private UdpClient _udp;

    private float _timer;

    private void Awake()

    {

        _udp = new UdpClient { EnableBroadcast = true };

        Logger.LogInfo($"Oxygen Tap: broadcasting on UDP {Port}");

    }

    private void Update()

    {

        _timer += Time.deltaTime;

        if (_timer < 0.25f) return;

        _timer = 0f;

        var player = Player.main;

        if (player == null || player.oxygenMgr == null) return;

        float o2  = player.oxygenMgr.GetOxygenAvailable();

        float cap = player.oxygenMgr.GetOxygenCapacity();

        byte[] data = Encoding.ASCII.GetBytes($"O2;{o2:F1};{cap:F1}");

        _udp.Send(data, data.Length, "255.255.255.255", Port);

    }

    private void OnDestroy() => _udp?.Dispose();

}

Для теста использовал простейший однострочник на PowerShell:

$u = New-Object Net.Sockets.UdpClient(17777); $e = New-Object Net.IPEndPoint([Net.IPAddress]::Any,0); while($true){ [Text.Encoding]::ASCII.GetString($u.Receive([ref]$e)) }

Как только сохранение загрузилось, начали поступать данные:

Получаемые по UDP данные
Получаемые по UDP данные

Продолжение следует…

Пройти первые два этапа и написать плагин к Subnautica оказалось весьма простым, но увлекательным занятием. Совсем скоро, во второй части статьи я расскажу, как делал концепт дизайна циферблата на Python и готовил симуляцию работы прошивки через Wokwi, пока ожидал прибытия заказанных контроллеров и дисплеев из Поднебесной.

Приходилось ли вам когда-нибудь создавать нечто подобное? Поделитесь своим опытом в комментариях, будет интересно почитать. Если вы еще не подписаны на блог МТС — сделайте это сейчас, чтобы не пропустить новые статьи. 

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


  1. xSVPx
    21.07.2026 12:53

    Индикаторы есть уже готовые такие круглые, в корпусе с esp32 внутри и тачб экраном...


  1. himch
    21.07.2026 12:53

    Хах, думал это будет реальный девайс для погружений)


    1. xSVPx
      21.07.2026 12:53

      Есть же готовые. Но там какие-то заморочки с тем как датчик прикручивается. Не удивлюсь если он бле и можно к нему любой свой монитор сделать.

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