Лимит загрузки креативов Unity Ads на апку: 30 за 30 минут
Лимит Unity Ads: 1 креатив в секунду и 30 за 30 минут на каждую апку, пачка считается поштучно. Прикидываем залив и что не сказано в доках.
КреативыUnity API5 мин чтения

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

Чтобы превратить 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.
Ставим плагин:
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-чанк.
Без плагина Vite встраивает в base64 только маленькие файлы. По документации Vite, порог build.assetsInlineLimit по умолчанию 4096 байт (4 КиБ): всё мельче встраивается, всё крупнее остаётся отдельным файлом. Картинка на 200 КБ в такой сборке превратится в файл рядом с index.html, а ссылка на неё — в тот самый относительный путь, из-за которого в рекламном юните бывает пустой экран. Плагин убирает порог, но три места остаются на вас:
public. Vite копирует её содержимое как есть, не встраивая, и плагин этого тоже не делает. Всё, что должно оказаться внутри файла, импортируйте из src, а не кладите в public. Ссылки на public в index.html (например, на иконку) удалите.Импорт картинки в коде выглядит так, и после сборки hero содержит строку data:image/png;base64,...:
import hero from "./assets/hero.png";
Загрузчик 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 лишь запасной (подробнее в статье про требования).
Сверяем 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 ничего не должно запрашиваться.
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: десктоп её не заменяет.
load.image ссылку data:.В DarkCore Ramet есть Playable Builder. Видео в разделе креативов загружается один раз и уходит во все нужные апки.
Видео загружается один раз и идёт во все нужные апки
DarkCore Ramet заливает видео в Unity Ads через официальный API: загруженное один раз уходит во все нужные апки.
Лимит Unity Ads: 1 креатив в секунду и 30 за 30 минут на каждую апку, пачка считается поштучно. Прикидываем залив и что не сказано в доках.
КреативыUnity API5 мин чтения

Playable для Unity Ads: один index.html меньше 5 МБ, base64, MRAID 3.0, без сети, стор по тапу на CTA. Чек-лист по группам и что проверяет модерация.
PlayableМодерация6 мин чтения

Требования Unity, AppLovin, Meta и Mintegral к playable: формат, размер, MRAID, ориентация, CTA. Одна сборка на все сети по докам не подтверждается.
PlayableMRAID4 мин чтения
