Перейти к содержимому

Из HTML5-игры в playable одним index.html: Vite и Phaser

Как собрать HTML5-игру на Vite и Phaser в один index.html для Unity Ads: vite-plugin-singlefile, текстуры из base64 и проверка файла по списку Unity.

Опубликовано
6 мин чтения
6 мин чтения
English
Українська
В статье
  1. Что должно получиться на выходе
  2. Шаг 1. Vite и vite-plugin-singlefile
  3. Шаг 2. assetsInlineLimit и что остаётся снаружи
  4. Шаг 3. Phaser: текстуры из base64, а не по URL
  5. Шаг 4. Проверка итогового файла по списку Unity
  6. Шаг 5. Сборка, которая запускается без MRAID
  7. Что мы не проверяли
  8. Что здесь делает DarkCore Ramet

Чтобы превратить HTML5-игру в playable для Unity Ads, на выходе нужен один index.html меньше 5 МБ, где вся графика и звук лежат внутри как base64. На Vite это делает плагин vite-plugin-singlefile, а в Phaser вместо this.load, который тянет файлы по URL, текстуры можно подать через addBase64. Мы сами такой конвейер не собирали и не тестировали: ниже рецепт из документации инструментов и требований Unity.

Что должно получиться на выходе

Требования берём из спецификации playable в доках Unity. Это один файл index.html меньше 5 МБ. Все картинки, аудио и видео вшиты в него как base64. Ссылок на другие файлы и папки по относительным путям или удалённым URL быть не должно, код должен быть минифицирован. Playable работает без сетевых запросов (XHR) и не подгружает картинки или конфиг с сервера во время показа.

Плюс одно требование из best practices: итоговая сборка должна запускаться и без MRAID, чтобы целиком проверить её в десктопном браузере. Полный список требований — в статье про требования Unity Ads к playable.

Шаг 1. Vite и vite-plugin-singlefile

Ставим плагин:

npm install -D vite-plugin-singlefile

Конфиг:

// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],
});

Сборка (npx vite build) кладёт результат в dist/index.html. По документации плагина, он встраивает весь JavaScript и CSS прямо в этот файл. С рекомендуемой настройкой сборки он ещё задаёт build.assetsInlineLimit так, что каждый адрес ассета превращается в data URI, отключает разбиение CSS (build.cssCodeSplit = false), ставит base = "./" и собирает один JS-чанк.

Шаг 2. assetsInlineLimit и что остаётся снаружи

Без плагина Vite встраивает в base64 только маленькие файлы. По документации Vite, порог build.assetsInlineLimit по умолчанию 4096 байт (4 КиБ): всё мельче встраивается, всё крупнее остаётся отдельным файлом. Картинка на 200 КБ в такой сборке превратится в файл рядом с index.html, а ссылка на неё — в тот самый относительный путь, из-за которого в рекламном юните бывает пустой экран. Плагин убирает порог, но три места остаются на вас:

  • Папка public. Vite копирует её содержимое как есть, не встраивая, и плагин этого тоже не делает. Всё, что должно оказаться внутри файла, импортируйте из src, а не кладите в public. Ссылки на public в index.html (например, на иконку) удалите.
  • SVG. Vite не встраивает SVG напрямую, поэтому плагин тоже. Вставьте разметку в шаблон или подключите плагин-загрузчик SVG.
  • Всё остальное. README плагина предупреждает, что бывают и другие случаи, когда файлы по ссылкам не встраиваются. Поэтому итоговый файл нужно проверить (шаг 4).

Импорт картинки в коде выглядит так, и после сборки hero содержит строку data:image/png;base64,...:

import hero from "./assets/hero.png";

Шаг 3. Phaser: текстуры из base64, а не по URL

Загрузчик Phaser (this.load.image, this.load.json и остальные) забирает файлы по URL: в документации его типы файлов принимают настройки XHR. Для playable в Unity Ads это запрещённые сетевые запросы. Вместо них вызываем TextureManager.addBase64(key, data): он создаёт DOM-объект Image и выставляет источник асинхронно. Документация Phaser просит слушать события метода (ADD, ERROR, LOAD), прежде чем использовать текстуру. Принимает ли load.image ссылку вида data:, мы не проверяли.

Рабочий вариант: отдельная сцена, которая по одной добавляет текстуры, ждёт LOAD и только потом запускает игру.

import Phaser from "phaser";
import hero from "./assets/hero.png";
import bg from "./assets/bg.jpg";

const images = { hero, bg };

function addBase64(tm: Phaser.Textures.TextureManager, key: string, data: string) {
  return new Promise<void>((resolve, reject) => {
    tm.once(Phaser.Textures.Events.LOAD, () => resolve());
    tm.once(Phaser.Textures.Events.ERROR, () => reject(new Error(`texture ${key}`)));
    tm.addBase64(key, data);
  });
}

export class Boot extends Phaser.Scene {
  async create() {
    // По одной, чтобы не сопоставлять события с ключами.
    for (const [key, data] of Object.entries(images)) {
      await addBase64(this.textures, key, data);
    }
    this.scene.start("Game");
  }
}

Слушатель второго события после завершения промиса остаётся, но он безвреден: повторный вызов resolve или reject ничего не меняет. Если текстур много, добавляйте их параллельно и различайте события по ключу, сигнатуры событий сверьте с документацией вашей версии Phaser.

Что ещё нельзя: this.load.* с путями, fetch и XHR за JSON или шрифтом, веб-шрифты по URL. JSON и атласы импортируйте как модули: они попадут в JS-бандл. Звук и видео в этой статье не разбираем: как подавать их в Phaser из base64, рассмотренная документация не описывает. Из требований Unity известно только, что звук должен выключаться по переключателю на iOS: основной путь — Web Audio API, HTMLAudioElement лишь запасной (подробнее в статье про требования).

Шаг 4. Проверка итогового файла по списку Unity

Сверяем dist с пунктами спецификации:

ls dist                  # должен остаться один index.html, без папки assets
wc -c < dist/index.html  # размер в байтах: меньше 5 МБ, лучше с запасом
grep -noE "(src|href)=\"[^\"]{0,60}|url\([^)]{0,60}" dist/index.html | grep -v "data:"

Последняя команда ищет атрибуты и url(...), которые не начинаются с data:, и в идеале печатает пустоту. Она не ловит пути, собранные в JavaScript, поэтому дополнительно поищите в бандле fetch(, XMLHttpRequest и вызовы load.. Как определить 5 МБ (десятичные или двоичные), Unity не уточняет, поэтому лучше уложиться с запасом. Минификацию в продакшен-сборке Vite делает по умолчанию, но проверьте глазами, что файл не читается как обычный код с отступами. И откройте его в DevTools на вкладке Network в режиме Offline: в чистом playable ничего не должно запрашиваться.

Шаг 5. Сборка, которая запускается без MRAID

Unity вставляет MRAID в webview, поэтому на рекламном юните он есть, а в десктопном браузере нет. Одна и та же сборка должна работать в обоих случаях, поэтому в точке входа добавьте ветку без MRAID:

declare const mraid: {
  getState(): string;
  isViewable(): boolean;
  addEventListener(event: string, listener: (...args: unknown[]) => void): void;
};

export function boot(start: () => void, pause: () => void) {
  // Десктоп: MRAID нет, запускаем сразу.
  if (typeof mraid === "undefined") return start();
  const onReady = () => {
    mraid.addEventListener("viewableChange", (v) => (v ? start() : pause()));
    if (mraid.isViewable()) start();
  };
  if (mraid.getState() === "loading") mraid.addEventListener("ready", onReady);
  else onReady();
}

Это сокращённая версия примера из спецификации Unity (адаптировано). start должен переживать повторный вызов: в примере Unity startPlayable() вызывается и для первого запуска, и для возобновления после паузы. После десктопного теста всё равно нужна проверка на реальном устройстве через приложение Ad Testing: десктоп её не заменяет.

Что мы не проверяли

  • Не собирали и не запускали конвейер Vite + Phaser целиком: рецепт собран из документации Vite, плагина и Phaser.
  • Не проверяли, принимает ли load.image ссылку data:.
  • Не проверяли подачу аудио и видео из base64 в Phaser.
  • Лимиты на размер отдельных ассетов, кроме общих 5 МБ, в спецификации Unity не названы.

Что здесь делает DarkCore Ramet

В DarkCore Ramet есть Playable Builder. Видео в разделе креативов загружается один раз и уходит во все нужные апки.

В DarkCore Ramet

Видео загружается один раз и идёт во все нужные апки

DarkCore Ramet заливает видео в Unity Ads через официальный API: загруженное один раз уходит во все нужные апки.

Все статьи