Лаунчер для Electronvolt

Майские праздники не прошли даром: на пару дней я основательно засел за Python и написал собственный лаунчер. Зачем? Он нужен для того, чтобы не привязывать игру к конкретной игровой платформе. В моем случае, я использую GameJolt, но такой подход работает для любого сервиса. Лаунчер служит посредником между игрой и API площадки. Все, что нужно делать игре — это сообщать лаунчеру о событиях наподобие «игрок получил такую-то ачивку». Лаунчер, в свою очередь, передает эти данные игровому сервису. Если надо переехать на другой сервис, достаточно просто обновить лаунчер, а игру патчить не придется — это удобно и экономит массу времени.

Для создания интерфейса я использовал WebView. Можно спорить до хрипоты о недостатках веб-стека, но на сегодняшний день он остается самым простым и универсальным решением для создания GUI. Нативные тулкиты платформозависимы, требуют соблюдения особой архитектуры приложения и чаще всего выглядят скучновато, а браузерный движок позволяет буквально за несколько часов наваять красивые формы с анимациями и спецэффектами — сделал и забыл!

Долгое время я писал веб-интерфейсы на CEF (Chromium Embedded Framework) — «Electron для Python». Он предоставляет portable браузерный движок и в целом свою задачу решает, но имеет ряд проблем. CEF Python уже несколько лет как не развивается, и версия Chromium в нем заметно устарела, новейшие свойства CSS не поддерживаются. Кроме того, в собранном виде CEF-приложение весит около 200 Мб, что для небольшого приложения, мягко говоря, жирновато. Все эти проблемы я решил переходом на PyWebView! Единственное, чем придется пожертвовать — совместимостью с Windows 8, что в 2025 году совсем не критично. Собранный PyInstaller’ом, лаунчер стал весить менее 50 Мб (используя UPX, можно сжать еще сильнее). А код под PyWebView гораздо компактнее и проще, чем под CEF Python.

Основная задача моего лаунчера — служить посредником между игрой и внешними сервисами. Для этого используется механизм IPC: игра отправляет события лаунчеру через TCP-соединение по кастомному протоколу. Например, когда игрок получает достижение, игра отправляет команду типа "eV:award=AchievementName". Лаунчер, получив такую команду, передает данные на сервер.

Важная особенность этого механизма — асинхронность: блокировать игровой цикл, чтобы отправить что-то по сети — не комильфо, задержки бывают в секунду и даже дольше. Выручил taskPool из std.parallelism — все вызовы IPC оборачиваются в task и заносятся в очередь пула задач:

taskPool.put(task!ipcSendSync(message));

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

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

Лаунчер также позволяет пользователю настроить графику игры перед запуском. Для этого он взаимодействует с конфигурационным файлом игры — settings.conf. Лаунчер считывает текущие настройки, обновляет их в зависимости от выбранных пользователем параметров и сохраняет изменения обратно в файл. Также он мониторит изменения в конфиге — если игра обновила файл, интерфейс лаунчера подхватит изменения.

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

Распараллеливание обработки изображений

API dlib.image позволяет создавать фильтры, которые легко распараллеливать на несколько процессоров. Изображение условно разбивается на несколько блоков заданного размера, которые затем обрабатываются фильтром через std.parallelism.

import std.parallelism;
import dlib.functional.range;
import dlib.image.image;

struct Block
{
    uint x1, y1;
    uint x2, y2;
}

alias Range!uint PixRange;

void parallelFilter(
     SuperImage img, 
     void delegate(PixRange blockRow, PixRange blockCol) ffunc, 
     uint bw = 100,
     uint bh = 100)
{
    if (bw > img.width)
        bw = img.width;
    if (bh > img.height)
        bh = img.height;

    uint numBlocksX = img.width / bw + ((img.width % bw) > 0);
    uint numBlocksY = img.height / bh + ((img.height % bh) > 0);

    Block[] blocks = new Block[numBlocksX * numBlocksY];
    foreach(x; 0..numBlocksX)
    foreach(y; 0..numBlocksY)
    {
        uint bx = x * bw;
        uint by = y * bh;

        uint bw1 = bw;
        uint bh1 = bh;

        if ((img.width - bx) < bw)
            bw1 = img.width - bx;
        if ((img.height - by) < bh)
            bh1 = img.height - by;

        blocks[y * numBlocksX + x] = Block(bx, by, bx + bw1, by + bh1);
    }

    foreach(i, ref b; taskPool.parallel(blocks))
    {
        ffunc(range!uint(b.x1, b.x2),
              range!uint(b.y1, b.y2));
    }
}

Пример (закрашивание сплошным цветом):

SuperImage filterTestMultithreaded(SuperImage img)
{
    auto res = img.dup;
    
    img.parallelFilter((PixRange row, PixRange col)
    {
        foreach(x; row)
        foreach(y; col)
        {
            res[x, y] = hsv(180.0f, 1.0f, 0.5f);
        }
    });
    
    return res;
}

Для сравнения — однопоточный вариант:

SuperImage filterTestSinglethreaded(SuperImage img)
{
    auto res = img.dup;
    
    foreach(x; img.row)
    foreach(y; img.col)
    {
        res[x, y] = hsv(180.0f, 1.0f, 0.5f);
    }
   
    return res;
}

На двухъядерном Intel Dual Core T2390 (1.86 ГГц) многопоточный вариант показывает прирост производительности на 70%.