Перейти до вмісту

З 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

Щоб зробити playable для Unity Ads з HTML5-гри, на виході потрібен один 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: завантажене раз іде в усі потрібні апки.

Усі статті