Архітектура Player
Чому Player Romergo працює на звичайному DOM
Рушій візуальної новели загалом не такий складний, як рушій тривимірного шутера або стратегії в реальному часі. Йому не потрібно щосекунди обчислювати фізику великого світу, поведінку сотень об'єктів і складне освітлення. Але це не означає, що достатньо просто змінювати картинки після натискання.
Звичайна сцена візуальної новели складається з досить зрозумілого набору:
- одного або кількох фонів;
- персонажів та їхніх позицій;
- репліки, імені й портрета того, хто говорить;
- візуальних ефектів і переходів;
- музики та звуків;
- вибору або іншої дії гравця.
Уся магія виникає з перемикання цих параметрів у часі. Важливо не лише що показати, а й коли це зробити, що залишити без змін, як продовжити музику між сценами, яку мову вибрати та куди перейти після дії гравця.
Я довго шукав рушій, а вибрав браузер
Я пробував будувати сцену на Godot, PixiJS і Three.js. З різним успіхом усе це працювало, і самі рушії тут ні в чому не винні. Але для мого способу створення квестів вони додавали шар, із яким доводилося домовлятися окремо.
Мені був потрібен не лише готовий ігровий екран. Для мене було важливо миттєво бачити будь-яку зміну в Builder, запускати одну сцену без складання всієї гри, виділяти й рухати елементи прямо в попередньому перегляді, а потім отримувати ту саму поведінку в опублікованому Player.
У підсумку найефективнішим виявилося найпростіше рішення: звичайний DOM браузера.
Фон, персонажі й діалог залишаються звичними браузерними шарами. Більша частина композиції та ефектів будується через CSS. Canvas 2D використовується для окремих візуальних елементів, а WebGL canvas підключається там, де справді потрібні шейдерні переходи між зображеннями.
Звук працює через браузерний HTMLAudioElement. Runtime створює аудіо для активних кліпів, керує гучністю та зацикленням і зберігає ту саму музику під час переходу, якщо її asset не змінився.
Це не універсальний рецепт для будь-якої гри. Але для візуальної новели DOM виявився не компромісом, а дуже точним інструментом.
Проста сцена й розумний runtime
Сам Player я подумки ділю на дві частини:
- «дурна» сцена показує й відтворює те, що їй передали;
- «розумний» 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. Player вибирає відповідний режим за пристроєм та орієнтацією, а потім масштабує готову сцену цілком.
Desktop-розкладка залишається основною. Для телефона можна задати окремі позиції та масштаб персонажів; якщо перевизначення немає, використовується desktop-варіант із додатковим зменшенням. Тому автор редагує не дві незалежні сцени, а одну сцену з точковими мобільними виправленнями.
Для фону bgX, bgY і масштаб спільні для обох режимів, а вертикальний viewport по-своєму обрізає те саме зображення. Тому перед випуском потрібно перевірити кадрування в обох форматах і вибрати спільний фокус. Окремі мобільні налаштування фону не входять до runtime-контракту.
Однотипні виправлення персонажів можна доручати AI-агенту через MCP, а потім перевіряти в справжньому попередньому перегляді обох viewport. Саме тому я називаю адаптацію напівавтоматичною, а не повністю автоматичною.
Як не показати гравцеві чорний екран
Проста сцена не допомагає, якщо потрібне зображення ще не надійшло мережею. Тому завантаження також стало частиною runtime-контракту.
Перед першим рендером Player збирає активні медіа стартової сцени й чекає на їх завантаження. У цей час гравець бачить нормальний індикатор прогресу, а не порожній фон. Після запуску runtime із невеликою затримкою прогріває ресурси поточної сцени та сцен, досяжних за два наступні кроки графа. За замовчуванням глибина дорівнює двом переходам уперед, включно з усіма можливими гілками на обох кроках.
Попереднє завантаження прив'язане до графа переходів, а його глибину можна змінювати окремо. Фіксована кількість сцен тут не використовується: лінійна послідовність і розгалуження створюють різне навантаження, навіть якщо формально попереду однакова кількість кроків.
Для гри офлайн є інший режим: користувач може заздалегідь завантажити весь квест. Тоді runtime-знімок, медіа й оболонка PWA залишаються в браузерному кеші та відкриваються без мережі. Попереднє завантаження наступної сцени відповідає за плавне онлайн-проходження, а повне завантаження — за справжній offline.
Telegram як оболонка, Discord як окрема система
Любов до браузерів особливо виправдала себе під час вбудовування Player у Telegram і Discord. В обох випадках усередині платформи відкривається той самий web runtime, тому сцену й правила проходження не довелося переписувати під новий ігровий рушій.
Для Telegram переважно знадобилися платформна сесія, запуск квесту, локальний прогрес і перехід між Mini App та звичайним Player. Сама гра залишилася тією самою.
Discord складніший, тому що кілька людей мають бачити один і той самий момент історії. У кожного учасника справді працює власний локальний runtime, але host вважається джерелом істини. Його Player надсилає стан під час змін і приблизно кожні 750 мілісекунд. Realtime-кімната приймає команду через WebSocket, зберігає знімок і розсилає його учасникам. Runtime глядачів застосовує віддалений стан, відновлює сцену та продовжує локальний відлік часу між синхронізаціями.
Таке розділення дало приємний результат: стан історії спільний, але мова й розмір екрана залишаються локальними. Один учасник може дивитися сцену на телефоні українською, інший — англійською на великому екрані, і обидва залишаються синхронізованими з host. Глядачі також можуть голосувати за варіанти відповіді, не перетворюючись при цьому на другого host.
Одна основа для всіх режимів
Player давно вийшов за межі базового набору «фон, персонажі та репліка». У ньому з'явилися розгалуження, змінні, контрольні точки, перемотування, інтерактивні інтерфейсні сцени, переклади, offline і синхронне проходження.
Але базове рішення витримало зростання. Сцена й надалі займається зображенням і звуком. Runtime й надалі займається станом і переходами. Browser API закривають рендеринг, медіа, кеш і вбудовування, а спеціальні шари додаються лише там, де без них справді не обійтися.
Завдяки цьому той самий Player вдається підтримувати в Builder preview, тестах, звичайній браузерній грі, Telegram і Discord. Для мене це хороший приклад того, як найпростіше й трохи грубе рішення може виявитися найгнучкішим — якщо правильно провести межу відповідальності.