Архитектура плеера
Почему плеер Romergo работает на обычном DOM
Движок визуальной новеллы в целом не так сложен, как движок трёхмерного шутера или стратегии в реальном времени. Ему не нужно каждую секунду считать физику большого мира, поведение сотен объектов и сложное освещение. Но это не значит, что достаточно просто менять картинки по клику.
Обычная сцена визуальной новеллы состоит из довольно понятного набора:
- одного или нескольких задников;
- персонажей и их позиций;
- реплики, имени и портрета говорящего;
- визуальных эффектов и переходов;
- музыки и звуков;
- выбора или другого действия игрока.
Вся магия возникает из переключения этих параметров во времени. Важно не только что показать, но и когда это сделать, что оставить неизменным, как продолжить музыку между сценами, какой язык выбрать и куда перейти после действия игрока.
Я долго искал движок, а выбрал браузер
Я пробовал собирать сцену на Godot, PixiJS и Three.js. С переменным успехом всё это работало, и сами движки здесь ни в чём не виноваты. Но для моего способа создания квестов они добавляли слой, с которым приходилось отдельно договариваться.
Мне нужен был не только готовый игровой экран. Мне было важно моментально видеть любое изменение в Builder, запускать одну сцену без сборки всей игры, выделять и двигать элементы прямо в предпросмотре, а затем получать то же поведение в опубликованном Player.
В итоге самым эффективным оказалось самое простое решение: обычный DOM браузера.
Задник, персонажи и диалог остаются привычными браузерными слоями. Большая часть композиции и эффектов строится через CSS. Canvas 2D используется для отдельных визуальных артефактов, а WebGL canvas подключается там, где действительно нужны шейдерные переходы между изображениями.
Звук работает через браузерный HTMLAudioElement. Runtime создаёт аудио для активных клипов, управляет громкостью и зацикливанием и сохраняет одну и ту же музыку при переходе, если её asset не изменился.
Это не универсальный рецепт для любой игры. Но для визуальной новеллы DOM оказался не компромиссом, а очень точным инструментом.
Простая сцена и умный runtime
Сам плеер я мысленно делю на две части:
- «глупая» сцена показывает и проигрывает то, что ей передали;
- «умный» runtime хранит состояние истории и решает, что должно произойти дальше.
Сцена не должна знать, почему появился конкретный персонаж, какое условие открыло вариант ответа или какая глава будет следующей. Она получает клипы, текущее время, язык, режим экрана и ссылки на медиа. Затем рисует результат и возвращает действия игрока.
Runtime получает описание квеста и состояние прохождения. Он знает текущую главу, узел и сцену, значения переменных, сделанные выборы, контрольные точки и завершённые главы. Когда игрок нажимает на сцену или выбирает ответ, runtime применяет эффекты, находит следующий шаг и снова отдаёт сцене готовое состояние.
Если сильно упростить, цикл выглядит так:
JSON квеста + сохранение
↓
runtime определяет текущий шаг
↓
сцена показывает клипы и медиа
↓
действие игрока возвращается в runtime
↓
следующий шаг — и цикл повторяется до финала
В опубликованной игре Player загружает зафиксированный runtime-снимок всего квеста. В Builder предпросмотр строит совместимый runtime из текущего черновика. Поэтому это не два похожих плеера, которые со временем начинают вести себя по-разному, а один общий RuntimePlayer, SceneStage и viewport в разных оболочках.
Как сохраняется непрерывность между сценами
При переходе runtime пересчитывает состояние новой сцены. Повторяющаяся музыка узнаётся по asset ID и продолжает играть без нового старта и повторного fade-in. Изображения загружает и кэширует браузер, поэтому повторный задник не означает повторную загрузку файла из сети.
То есть оптимизация живёт не в одном большом условии «ничего не передавать», а на правильных уровнях: runtime сохраняет непрерывность воспроизведения, resolver возвращает стабильные ресурсы, а браузерный кэш не скачивает уже известное медиа заново.
Два экрана одной сцены
Самой неприятной задачей для меня оказался не сам рендеринг, а адаптация под телефон. Простое уменьшение горизонтальной сцены делает персонажей слишком мелкими, диалог тесным, а важные детали задника легко уходят за край.
В Romergo у сцены есть два фиксированных виртуальных viewport: горизонтальный 1280 × 720 и вертикальный 390 × 693. Плеер выбирает подходящий режим по устройству и ориентации, а затем масштабирует готовую сцену целиком.
Desktop-раскладка остаётся основной. Для телефона можно задать отдельные позиции и масштаб персонажей; если переопределения нет, используется desktop-вариант с дополнительным уменьшением. Поэтому автор редактирует не две независимые сцены, а одну сцену с точечными мобильными поправками.
Для задника bgX, bgY и масштаб общие для обоих режимов, а вертикальный viewport по-своему обрезает то же изображение. Поэтому перед выпуском нужно проверить кадрирование в обоих форматах и подобрать общий фокус. Отдельные мобильные настройки задника не входят в runtime-контракт.
Однотипные поправки персонажей можно отдавать AI-агенту через MCP, а затем проверять в настоящем предпросмотре обоих viewport. Именно поэтому я называю адаптацию полуавтоматической, а не полностью автоматической.
Как не показать игроку чёрный экран
Простая сцена не помогает, если нужное изображение ещё не приехало по сети. Поэтому загрузка тоже стала частью runtime-контракта.
Перед первым рендером Player собирает активные медиа стартовой сцены и ждёт их загрузки. В это время игрок видит нормальный индикатор прогресса, а не пустой фон. После запуска runtime с небольшой задержкой прогревает ресурсы текущей сцены и сцен, достижимых за два следующих шага графа. По умолчанию глубина равна двум переходам вперёд, включая все возможные ветки на обоих шагах.
Предзагрузка привязана к графу переходов, а её глубину можно менять отдельно. Фиксированное количество сцен здесь не используется: линейная последовательность и развилка создают разную нагрузку, даже если формально впереди одинаковое число шагов.
Для игры офлайн есть другой режим: пользователь может заранее скачать весь квест. Тогда runtime-снимок, медиа и оболочка PWA остаются в браузерном кэше и открываются без сети. Предзагрузка следующей сцены отвечает за плавное онлайн-прохождение, а полная загрузка — за настоящий offline.
Telegram как оболочка, Discord как отдельная система
Любовь к браузерам особенно окупилась при встраивании плеера в Telegram и Discord. В обоих случаях внутри платформы открывается тот же web runtime, поэтому сцену и правила прохождения не пришлось переписывать под новый игровой движок.
Для Telegram в основном понадобились платформенная сессия, запуск квеста, локальный прогресс и переход между Mini App и обычным Player. Сама игра осталась той же.
Discord сложнее, потому что там несколько людей должны видеть один ход истории. У каждого участника действительно работает свой локальный runtime, но host считается источником истины. Его плеер отправляет состояние при изменениях и примерно каждые 750 миллисекунд. Realtime-комната принимает команду по WebSocket, сохраняет снимок и рассылает его участникам. Runtime зрителей применяет удалённое состояние, восстанавливает сцену и продолжает локальный отсчёт времени между синхронизациями.
Такое разделение дало приятный результат: состояние истории общее, но язык и размер экрана остаются локальными. Один участник может смотреть сцену на телефоне по-русски, другой — на большом экране по-английски, и оба остаются в потоке host. Зрители также могут голосовать за варианты ответа, не превращаясь при этом во второго host.
Одна основа для всех режимов
Плеер давно вышел за рамки базового набора «задник, персонажи и реплика». В нём появились ветвления, переменные, контрольные точки, перемотка, интерактивные интерфейсные сцены, переводы, offline и синхронное прохождение.
Но базовое решение выдержало рост. Сцена по-прежнему занимается изображением и звуком. Runtime по-прежнему занимается состоянием и переходами. Browser API закрывают рендеринг, медиа, кэш и встраивание, а специальные слои добавляются только там, где без них действительно нельзя.
Благодаря этому один и тот же плеер удаётся поддерживать в Builder preview, тестах, обычной браузерной игре, Telegram и Discord. Для меня это хороший пример того, как самое простое и немного топорное решение может оказаться самым гибким — если правильно провести границу ответственности.