1 Commits

Author SHA1 Message Date
d384c1be3a playworks converter 2026-05-27 15:13:58 +08:00
22 changed files with 1210 additions and 0 deletions

232
aiAssets/GUIDE.md Normal file
View File

@@ -0,0 +1,232 @@
# AGENTS.md — Playable Ads
## Stack
Phaser 3.90 · TypeScript · Vite + vite-plugin-singlefile · WebP · MP3 96kbps
Networks: Applovin (al) · GoogleAds (gg) · Ironsource (is) · Mintegral (mtg) · Facebook (fb) · Unity (un) · Vungle (vu) · Moloco (mo) · TikTok (tt)
## Porting to a new project
1. Update naming fields (3LC, vendor, concept name) in [scripts/build-all.mjs](scripts/build-all.mjs) — drives the output filename
2. Update store URLs in [constants.ts](src/constants.ts) (iOS + Android)
3. Adjust the Depth Map below to match the project's layer needs
4. Update [iteration.ts](src/iteration.ts) A/B config shape for this project's variants
5. Replace `Assets/` with project assets; update asset manifests in [BootScene.ts](src/scenes/BootScene.ts)
6. Rewrite `game/` modules for the new mechanic(s) — keep the single-responsibility split
7. Update this AGENTS.md's Depth Map and any project-specific pitfalls
## Structure
```
src/
main.ts # Bootstrap, resize, MRAID gating, lifecycle stubs on window
constants.ts # Design coords (1080×1920), store URLs
networks.ts # triggerCTA(), initMraid(), bindLifecycle(), notifyGameX()
analytics.ts # trackEvent() → SDK
iteration.ts # A/B iteration config
utils/
responsive.ts # sx(), sy(), sd() — coordinate helpers
scenes/
BootScene.ts # Critical preload + deferred asset manifest
GameScene.ts # Orchestrator only — wires game/ modules, handles lifecycle
cta.ts # End-card layout + redirectToStore() → notifyGameClose() + triggerCTA()
game/ # Single-responsibility modules (no direct ad-SDK calls)
# …one file per distinct mechanic or UI widget
Assets/
scripts/build-all.mjs # Single build → per-network HTML variants
```
### Responsibility split
- **`game/`** modules each own a single concern (one mechanic, one widget, one system).
They receive the Phaser scene as a constructor arg and own their own game objects.
They never call ad-SDK functions (`triggerCTA`, `notifyGameX`, `trackEvent`).
- **`GameScene.ts`** is the wiring layer: creates modules, passes data between them,
and calls SDK helpers at the correct lifecycle moments.
- **`scenes/cta.ts`** owns end-card presentation and the store-redirect sequence.
## Rules
- 60 FPS target; never below 30 on mid-range Android
- `dist/index.html` < 5 MB; WebP images, short audio
- No hardcoded px — `sx()`, `sy()`, `sd()` only
- No audio autoplay — unmute on first `pointerdown`
- `for` loops in game logic; pool objects; no GC
- `setDisplaySize(sd())` on images — never `setScale`
- Never dim via `body`/page background
## Phaser Config
```ts
{ type: Phaser.AUTO, transparent: true,
scale: { mode: Phaser.Scale.NONE, width: 1080, height: 1920 },
render: { antialias: true, pixelArt: false } }
```
`Scale.NONE` — manual sizing; CSS = viewport px; internal = viewport × DPR.
`antialias: true` — required to avoid jagged edges on scaled WebP assets (end-card logo, CTA, score pill). Never set to `false`.
## Responsive (1080×1920 ref)
```ts
const s = Math.min(viewW / 1080, viewH / 1920)
const offX = (viewW - 1080 * s) / 2
const offY = (viewH - 1920 * s) / 2
// sx/sy/sd apply offX, offY, s
```
- `update()` polls `visualViewport` per-frame for rotation
- On change: `game.scale.resize(vw*dpr, vh*dpr)``relayout()`
- `bindResponsiveResize()`: rAF debounce + 100/300/600ms retries
## CTA — SDK Priority (triggerCTA fallback chain)
```
1. ExitApi.exit() → GoogleAds (gg)
2. FbPlayableAd.onCTAClick() → Facebook (fb)
3. Luna.Unity.Playable → Unity (un)
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
5. window.install() → Mintegral (mtg)
6. window.openAppStore() → (runtime fallback)
7. window.clickTag → Moloco (mo)
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
10. mraid.open(url) → Applovin (al) / Ironsource (is)
11. window.open(storeUrl) → Fallback (all others)
```
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
## Analytics — SDK Priority
```
ALPlayableAnalytics.trackEvent(e) → Applovin (al)
playableSDK.reportEvent(e) → (runtime fallback if SDK present)
console.log('[Analytics]', e) → All other networks
```
Events: `DISPLAYED` · `CTA_CLICKED` · `ENDCARD_SHOWN` · `CHALLENGE_STARTED` · `CHALLENGE_SOLVED`
## Game Lifecycle (all networks)
`main.ts` exposes stubs on `window` so every network's preview tool detects them.
Stubs only set if the SDK hasn't already provided its own (`typeof` guard).
```
gameReady() → stub in main.ts; Mintegral also gets onload body attr from build
gameStart() → notifyGameStart() in GameScene.startPlaying()
gameEnd() → notifyGameEnd() in GameScene.triggerFail() / triggerWin()
gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCTA()
```
### Pause / Resume / Mute (bindLifecycle in networks.ts)
| Network | Mechanism |
|---|---|
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events |
| Mintegral | `window.message``onPause` / `onResume` |
| Vungle | `ad-event-pause` / `ad-event-resume` events |
| Applovin / Ironsource | MRAID viewable state (gated in initMraid) |
| GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility |
## Build
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
| Network | Tag | Injected | Zipped | Included |
|---|---|---|---|---|
| Applovin | al | `mraid.js` | — | ✓ |
| Google | gg | `exitapi.js` | ✓ | ✓ |
| Ironsource | is | `mraid.js` | — | ✓ |
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
| Facebook | fb | — | — | ✓ |
| Unity | un | — | — | ✓ |
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
| Moloco | mo | — | — | ✓ |
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
Networks marked **Zipped** ingest zipped creatives — [scripts/build-all.mjs](scripts/build-all.mjs) writes a `.zip` containing the HTML (one zip per variant, containing just that HTML at the root named index.html). Toggled per-network via `zip: true` in the `NETWORKS` array.
Networks marked **Included** are built by default. Networks not marked are prepared for but not built. Toggle per-network via `included: true` in the `NETWORKS` array.
Output is grouped by **iteration length** (e.g. `2words/`, `4words/`, `full/`) then by **network** (e.g. `Applovin/`, `Google/`, `Mintegral/`), with all per-network HTML or ZIP variants for that iteration sitting together inside the folder:
`dist/<length>/<3lc>_<creative-type>_<vendor>_<concept-name>_<concept-num>_<gameplay>_<ugc>_<seasonal>_<lang>_<length>_<size>_<network>`
Example layout:
```
dist/
2words/
Applovin/
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
Google/
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
index.zip
Ironsource/
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
…one HTML or ZIP per network…
4words/
full/
```
| Field | Description | Example |
|---|---|---|
| 3LC | 3-letter project code | `ws` (Wordscapes), `pp` (PeoplePuzzle) |
| Creative Type | Format identifier | `mip` |
| Vendor | Studio / vendor code | `grhpl` |
| Concept Name | Creative concept (append `var1`, `var2`… for variants) | `wbinspiredvar1`, `wbinspiredvar2`, `burglar` |
| Concept # | Iteration number, zero-padded | `01`, `02`, `03` |
| Gameplay | Gameplay style | `real`, `cartoon` |
| UGC | UGC presence | `na`, `ugc`, `nougc` |
| Seasonal | Seasonal tie-in | `noseason`, `xmas` |
| Language | Locale code | `en` |
| Length | Run length | `2words`, `4words`, `7words`, `full` (Wordscapes); `2clk`, `10clk`, `120sec` (other projects) |
| Size | Region / size code | `na` |
| Network | Network tag (last segment) | `al`, `gg`, `is`, `mtg`, `fb`, `un`, `vu`, `mo`, `tt` |
Examples (this project):
- `ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html` (cta2, default theme)
- `ws_mip_grhpl_wbinspiredvar1_04_real_na_noseason_en_full_na_fb.html` (full, default theme)
- `ws_mip_grhpl_wbinspiredvar2_01_real_na_noseason_en_2words_na_gg.html` (cta2, v1 theme)
Naming fields are defined in [scripts/build-all.mjs](scripts/build-all.mjs) — update them when porting.
## Asset Loading
- **Critical** (BootScene): first-frame assets, target < 200 KB
- **Deferred**: audio, overlays, secondary UI — loads during intro
- Gameplay start waits on a deferred-ready flag set when the secondary manifest finishes loading
## Depth Map (adjust per project)
Define depths as named constants in [constants.ts](src/constants.ts) — never use magic numbers in scene code.
| Layer | Depth |
|---|---|
| Background | 0 |
| Game objects | 110 |
| Logo | 17 |
| Dim overlay | 20 |
| Fail/win UI | 21 |
| EndCard input | 25 |
## Ship Checklist
- [ ] < 5 MB, all WebP
- [ ] FPS ≥ 55 under CPU throttle
- [ ] Portrait + landscape pass
- [ ] MRAID ready before gameplay
- [ ] Audio muted by default
- [ ] CTA fires in fallback
- [ ] No `console.error`
- [ ] No `type="module"` or `crossorigin` on `<script>` in output HTML
- [ ] Lifecycle: gameReady / gameStart / gameEnd / gameClose detected by all network preview tools
## Pitfalls
| Issue | Fix |
|---|---|
| Rotation stuck | `Scale.NONE` + per-frame poll |
| Mobile aliasing | Canvas = viewport × DPR |
| FPS drop on resize | rAF debounce |
| MRAID crash | `typeof mraid !== 'undefined'` guard |
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
| Bundle > 5 MB | WebP q75, shorter audio |
| Audio blocked | Gate on `pointerdown` |
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
| Network preview checklist fail | Expose lifecycle stubs on `window` + call `notifyX()` with `typeof` guards |
| `playableSDK.reportEvent is not a function` | Applovin preview injects `playableSDK` without `reportEvent` — guard with `typeof playableSDK.reportEvent === 'function'`, not just `typeof playableSDK !== 'undefined'` |
| iPhone black screen (Android works) | **Only one `Phaser.Game` per page, ever.** iOS WKWebView — used by Applovin/Ironsource preview apps and most in-app ad containers — caps or outright rejects the 2nd WebGL context. If a background scene is spun up as a separate `new Phaser.Game()` synchronously at module load, it can throw before `await initMraid()` and before the font promise resolves, so the rest of `main.ts` never runs → black screen with no visible error. Render the background as a CSS `background-image` on `#bg-container` (cover-sized) instead — zero WebGL contexts for the background, and nothing to throw at module-load time. iOS-debug path: enable Safari → Advanced → Web Inspector on the iPhone, connect to a Mac, open Develop → iPhone → preview WebView, read the Console. Without a Mac, temporarily install top-of-`main.ts` `window.addEventListener('error',…)` and `unhandledrejection` handlers that write the error into `document.body.innerHTML` so the failure is visible on-screen. |
| iOS hangs on font load | `document.fonts.load()` silently stalls in WKWebView when `@font-face` uses base64 `data:` URLs. Race against a 1.5 s timeout: `Promise.race([Promise.all([…fonts]), new Promise(r=>setTimeout(r,1500))]).then(startGame)` |
| iOS hangs on MRAID ready | `mraid.addEventListener('ready', …)` can miss the event if the container fires it before the listener attaches. `initMraid()` must self-resolve after ~2 s so `startGame()` always runs; also check `mraid.getState() !== 'loading'` first. |
| External `src=` survives build | `vite-plugin-singlefile` only inlines assets *imported from JS*. A raw `<img src="src/assets/…">` in `index.html` ships as a literal relative path and fails in ad sandboxes, producing inconsistent per-device load failures. Leave DOM `<img>` elements with no `src`; assign via JS from `assets.ts` at boot (`setEndcardHtmlAssets()`). |
| Audio keeps playing when ad hides | Add `visibilitychange` listener in the sound module — mute Phaser's sound system and `suspend()` the `AudioContext` when `document.hidden`, resume on return. Spec requires audio stop on hide/close. |
| `mraid.open()` silently no-ops | Guard with `mraid.getState() !== 'loading'` before calling; fall through to `window.open()` if still loading. |
| Previewer black screen with `Cannot read properties of null (reading 'appendChild')` in Phaser boot | Two compounding causes: (1) `vite-plugin-singlefile` hoists the bundled script into `<head>`, and the build strips `type="module"`, so the script runs **synchronously before `<body>` is parsed**`document.body` is null. Wrap main.ts in a `DOMContentLoaded` gate (`if (document.readyState === 'loading') addEventListener('DOMContentLoaded', boot) else boot()`). (2) `parent: 'game'` (string selector) fails when the previewer sandbox strips custom DOM; create `<div id="game">` at runtime inside `boot()` and pass the **element reference** (not the string) to `Phaser.Game({ parent })`. The cascading `ScaleManager.resize → Cannot set properties of undefined (setting 'width')` errors all trace back to `game.canvas` never being created when either of these fail. |
| NineSlice won't shrink in landscape | Phaser's `NineSlice` silently clamps its minimum dimensions to `(leftWidth + rightWidth) × (topHeight + bottomHeight)` **in source pixels**. Symptom: one UI chrome element stays huge in landscape while every other `sd()`-driven element rescales correctly — because the requested `sd(H)` is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: **operate the NineSlice in source-pixel space and scale it uniformly with `setScale(sd(1))`**. Keep `setSize(wSrc, hSrc)` in source px so the center stretches horizontally with text growth while corners stay intact; the `sd(1)` scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading `this.preview.width` (which is in scaled px) to decide `wSrc`, divide by `sd(1)` to convert back to source space. Tween callers that reset `setScale(1)` also need updating — the bg's base scale is now `sd(1)`, not 1. See [SelectionLine.ts](src/game/SelectionLine.ts) for the pattern. |
| Unity (un) submission: "not allowed to use window.top" | Luna's static scan flags the literal `window.top`, which Phaser 3 ships internally. In `build-all.mjs`, rewrite `window.top``window.self` for the `un` HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top. |
## Ironsource — Runtime Analysis
Ironsource requires every `_is.html` build to pass **LevelPlay → Creative Management → Playable Workshop** (Runtime Analysis / Playable Validator) before launch. Skipping this can get the ad disabled. The validator runs in an iOS-like WKWebView sandbox and exposes console errors — use it as a free diagnostic channel for iPhone-only failures when a Mac + Safari remote-debug isn't available.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -37,6 +37,10 @@
{
"command": "hplToolbox.openSendToMobile",
"title": "HPL Toolbox: Open Send To Mobile"
},
{
"command": "hplToolbox.openPlayworksConverter",
"title": "HPL Toolbox: Open Playworks Converter"
}
],
"viewsContainers": {

View File

@@ -5,6 +5,7 @@ import { openApplovinUpload } from './tools/applovinUpload';
import { openBase64Scanner } from './tools/base64Scanner';
import { openDailyUpdate } from './tools/dailyUpdate';
import { openSendToMobile } from './tools/sendToMobile';
import { openPlayworksConverter } from './tools/playworksConverter';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
@@ -13,6 +14,7 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
vscode.commands.registerCommand('hplToolbox.openDailyUpdate', () => openDailyUpdate(context)),
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
);
}

View File

@@ -59,6 +59,10 @@ function getHtml(version: string): string {
Send To Mobile
<span class="tool-desc">Share HTML to a phone via LAN + QR</span>
</button>
<button class="tool-btn" data-cmd="hplToolbox.openPlayworksConverter">
Playworks Converter
<span class="tool-desc">Convert Playworks HTML to per-network variants</span>
</button>
<script>
const vscode = acquireVsCodeApi();
document.querySelectorAll('.tool-btn').forEach((btn) => {

View File

@@ -0,0 +1,504 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as cp from 'child_process';
import { singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
interface ConvertOptions {
sourcePath: string;
outputDir: string;
networks: string[];
}
interface NetworkResult {
network: string;
file: string;
ok: boolean;
error?: string;
}
export function openPlayworksConverter(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.playworksConverter', 'Playworks Converter');
if (!isNew) return;
panel.webview.html = getHtml();
panel.webview.onDidReceiveMessage(async (msg) => {
switch (msg.type) {
case 'pickSource': {
const picked = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
filters: { HTML: ['html', 'htm'] },
openLabel: 'Select Playworks HTML',
});
if (!picked?.[0]) return;
const srcPath = picked[0].fsPath;
const dir = path.dirname(srcPath);
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir });
break;
}
case 'pickOutput': {
const picked = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: 'Select Output Folder',
});
if (!picked?.[0]) return;
panel.webview.postMessage({ type: 'outputPicked', path: picked[0].fsPath });
break;
}
case 'convert': {
const opts: ConvertOptions = msg.opts;
if (!fs.existsSync(opts.sourcePath)) {
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Source file not found.' });
return;
}
if (!fs.existsSync(opts.outputDir)) {
try { fs.mkdirSync(opts.outputDir, { recursive: true }); } catch {
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Could not create output folder.' });
return;
}
}
let html: string;
try {
html = fs.readFileSync(opts.sourcePath, 'utf8');
} catch (e: any) {
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
return;
}
const results: NetworkResult[] = [];
for (const network of opts.networks) {
try {
const filePath = await convertNetwork(html, opts, network);
results.push({ network, file: filePath, ok: true });
} catch (e: any) {
results.push({ network, file: '', ok: false, error: e.message });
}
}
panel.webview.postMessage({ type: 'convertDone', results });
break;
}
case 'openOutput': {
vscode.env.openExternal(vscode.Uri.file(msg.dir));
break;
}
}
});
}
const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']);
function sourceBaseName(sourcePath: string): string {
return path.basename(sourcePath, path.extname(sourcePath))
.replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, '');
}
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
const transformed = transformHtml(html, network);
const needsZip = ZIPPED_NETWORKS.has(network);
const baseName = sourceBaseName(opts.sourcePath);
const htmlFileName = `${baseName}_${network}.html`;
if (needsZip) {
const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`);
const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`);
fs.writeFileSync(tmpPath, transformed, 'utf8');
try {
await createZip(tmpPath, 'index.html', zipPath);
} finally {
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
return zipPath;
}
const outPath = path.join(opts.outputDir, htmlFileName);
fs.writeFileSync(outPath, transformed, 'utf8');
return outPath;
}
function transformHtml(html: string, network: string): string {
let result = html;
// Inject into </head>: lifecycle stubs only (must register luna:build listener
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
let headInject = buildLifecycleScript();
if (network === 'al' || network === 'is') {
headInject += '<script src="mraid.js"></script>';
} else if (network === 'gg') {
headInject += '<script src="exitapi.js"></script>';
}
result = result.replace('</head>', headInject + '\n</head>');
// Body-level flags
if (network === 'vu') {
result = result.replace('<body>', '<body>\n<script>window.__VUNGLE__=true;</script>');
} else if (network === 'mtg') {
result = result.replace('<body>', '<body onload="gameReady()">');
} else if (network === 'tt') {
result = result.replace('<body>', '<body>\n<script>window.__TIKTOK__=true;</script>');
}
// Replace the CTA script
result = replaceCTAScript(result, network);
// Unity: rewrite window.top → window.self (Luna static scan requirement)
if (network === 'un') {
result = result.split('window.top').join('window.self');
}
return result;
}
function replaceCTAScript(html: string, network: string): string {
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
const markerIdx = html.lastIndexOf(marker);
if (markerIdx === -1) return html;
const scriptOpenIdx = html.lastIndexOf('<script>', markerIdx);
if (scriptOpenIdx === -1) return html;
const scriptCloseIdx = html.indexOf('</script>', markerIdx);
if (scriptCloseIdx === -1) return html;
const before = html.slice(0, scriptOpenIdx);
const after = html.slice(scriptCloseIdx + '</script>'.length);
// Console restore runs after the bundle (which overrides console), before CTA
return before + buildConsoleRestoreScript() + buildCTAScript(network) + after;
}
// Restores native console methods overridden by the Playworks bundle,
// using a temporary iframe to get a fresh un-patched console reference.
function buildConsoleRestoreScript(): string {
return [
'<script>(function(){',
'try{',
'var f=document.createElement("iframe");',
'f.style.display="none";',
'document.documentElement.appendChild(f);',
'var nc=f.contentWindow.console;',
'document.documentElement.removeChild(f);',
'var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];',
'methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});',
'}catch(e){}',
'})();</script>',
].join('');
}
// Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events.
// Registered in <head> so our luna:build listener fires BEFORE the Playworks bundle
// listeners (which synchronously dispatch luna:start inside their luna:build handler).
// One-time guards prevent double-calls:
// - gameReady: luna:build fires it once; mtg's onload="gameReady()" also fires it
// - gameStart: luna:start fires twice on non-MRAID networks (two Playworks listeners)
function buildLifecycleScript(): string {
return [
'<script>(function(){',
'if(typeof window.gameReady!=="function")window.gameReady=function(){};',
'if(typeof window.gameStart!=="function")window.gameStart=function(){};',
'if(typeof window.gameEnd!=="function")window.gameEnd=function(){};',
'if(typeof window.gameClose!=="function")window.gameClose=function(){};',
'var _grOnce=false,_gsOnce=false;',
'window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});',
'window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});',
'window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});',
'})();</script>',
].join('');
}
function buildCTAScript(network: string): string {
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
// gameClose must fire before every redirect
const closeCall = `try{window.gameClose();}catch(e){}`;
let ctaLogic: string;
switch (network) {
case 'al':
case 'is':
// MRAID — mraid.js injected in <head>
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`;
break;
case 'un':
// Unity Ads — MRAID-based; window.top already rewritten separately
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`;
break;
case 'fb':
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
break;
case 'gg':
// Google — exitapi.js injected in <head>
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
break;
case 'mo':
ctaLogic = `${closeCall}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
break;
case 'vu':
// Vungle — window.__VUNGLE__ flag set in body
ctaLogic = `${closeCall}try{parent.postMessage("download","*");}catch(e){}`;
break;
case 'mtg':
// Mintegral — onload="gameReady()" set on body
ctaLogic = `${closeCall}if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`;
break;
case 'tt':
// TikTok — window.__TIKTOK__ flag set in body
ctaLogic = `${closeCall}if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`;
break;
default:
ctaLogic = `${closeCall}window.open(o,"_blank");`;
}
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),${urlSetup}${ctaLogic}}}))}));</script>`;
}
function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise<void> {
return new Promise((resolve, reject) => {
// Remove existing zip if present (Compress-Archive would fail otherwise)
if (fs.existsSync(destZipPath)) {
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
}
const psScript = [
'Add-Type -AssemblyName System.IO.Compression.FileSystem;',
`$zip=[System.IO.Compression.ZipFile]::Open('${destZipPath}','Create');`,
`[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,'${srcFilePath}','${nameInZip}');`,
'$zip.Dispose()',
].join(' ');
const proc = cp.spawn('powershell.exe', ['-NonInteractive', '-Command', psScript], { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Zip failed (code ${code}): ${stderr.trim()}`));
}
});
proc.on('error', reject);
});
}
// ─── Webview UI ────────────────────────────────────────────────────────────────
function getHtml(): string {
const networks = [
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
{ tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' },
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
{ tag: 'mo', label: 'Moloco', note: 'HTML' },
{ tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
];
const checkboxRows = networks.map(n => `
<label class="net-row">
<input type="checkbox" class="net-cb" data-tag="${n.tag}" checked />
<span class="net-label">${n.label} <span class="net-tag">${n.tag}</span></span>
<span class="net-note">${n.note}</span>
</label>`).join('');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 10px; font-size: 13px; }
h2 { font-size: 14px; margin: 0 0 14px 0; font-weight: 600; }
.section { margin-bottom: 14px; }
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.65; margin-bottom: 5px; }
.row { display: flex; gap: 6px; align-items: center; }
input[type=text] {
flex: 1; padding: 4px 7px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, #555);
border-radius: 2px; font-size: 12px;
}
button {
padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 2px;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: 1px solid var(--vscode-panel-border, #444);
}
button:hover { background: var(--vscode-button-secondaryHoverBackground); }
button.primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none; padding: 6px 18px; font-size: 13px;
}
button.primary:hover { background: var(--vscode-button-hoverBackground); }
button:disabled { opacity: 0.45; cursor: default; }
.net-grid { display: flex; flex-direction: column; gap: 4px; }
.net-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
.net-label { min-width: 100px; font-weight: 500; }
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
.net-note { font-size: 11px; opacity: 0.6; }
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
.toggle-row button { font-size: 11px; padding: 2px 8px; }
.actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; }
#status { margin-top: 12px; font-size: 12px; opacity: 0.75; min-height: 16px; }
.results { margin-top: 10px; display: flex; flex-direction: column; gap: 3px; }
.res-row { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: var(--vscode-editor-font-family); }
.mark { font-weight: bold; width: 14px; flex-shrink: 0; }
.ok { color: #3fb950; }
.bad { color: #f85149; }
.res-net { min-width: 70px; font-weight: 500; }
.res-file { opacity: 0.65; word-break: break-all; }
.res-err { color: #f85149; opacity: 0.9; }
hr { border: none; border-top: 1px solid var(--vscode-panel-border, #444); margin: 14px 0; }
</style>
</head>
<body>
<h2>Playworks Converter</h2>
<div class="section">
<div class="section-label">Source HTML (Playworks export)</div>
<div class="row">
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly />
<button id="pickSrc">Browse...</button>
</div>
</div>
<div class="section">
<div class="section-label">Output Folder</div>
<div class="row">
<input id="outDir" type="text" placeholder="Output folder..." />
<button id="pickOut">Browse...</button>
</div>
</div>
<hr />
<div class="section">
<div class="section-label">Networks to generate</div>
<div class="toggle-row">
<button id="selectAll">All</button>
<button id="selectNone">None</button>
</div>
<div class="net-grid">${checkboxRows}</div>
</div>
<div class="actions">
<button class="primary" id="convert" disabled>Convert</button>
<button id="openOut" style="display:none">Open Output Folder</button>
</div>
<div id="status"></div>
<div class="results" id="results"></div>
<script>
const vscode = acquireVsCodeApi();
let outputDir = '';
const srcEl = document.getElementById('src');
const outEl = document.getElementById('outDir');
const convertBtn = document.getElementById('convert');
const openOutBtn = document.getElementById('openOut');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
function updateConvertBtn() {
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
}
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
document.getElementById('selectAll').addEventListener('click', () => {
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
updateConvertBtn();
});
document.getElementById('selectNone').addEventListener('click', () => {
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
updateConvertBtn();
});
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
outEl.addEventListener('input', updateConvertBtn);
openOutBtn.addEventListener('click', () => {
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
});
document.getElementById('convert').addEventListener('click', () => {
const networks = [...document.querySelectorAll('.net-cb')]
.filter(c => c.checked).map(c => c.dataset.tag);
statusEl.textContent = 'Converting...';
resultsEl.innerHTML = '';
openOutBtn.style.display = 'none';
convertBtn.disabled = true;
vscode.postMessage({
type: 'convert',
opts: {
sourcePath: srcEl.value,
outputDir: outEl.value,
networks,
},
});
});
window.addEventListener('message', (event) => {
const msg = event.data;
if (msg.type === 'sourcePicked') {
srcEl.value = msg.path;
if (!outEl.value && msg.dir) {
outEl.value = msg.dir;
outputDir = msg.dir;
}
updateConvertBtn();
} else if (msg.type === 'outputPicked') {
outEl.value = msg.path;
outputDir = msg.path;
updateConvertBtn();
} else if (msg.type === 'convertDone') {
convertBtn.disabled = false;
updateConvertBtn();
if (msg.error) {
statusEl.textContent = 'Error: ' + msg.error;
return;
}
const results = msg.results || [];
const ok = results.filter(r => r.ok).length;
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
resultsEl.innerHTML = '';
for (const r of results) {
const row = document.createElement('div');
row.className = 'res-row';
const mark = document.createElement('span');
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
mark.textContent = r.ok ? '✓' : '✗';
const net = document.createElement('span');
net.className = 'res-net';
net.textContent = r.network;
const detail = document.createElement('span');
if (r.ok) {
detail.className = 'res-file';
detail.textContent = r.file.split(/[\\\\/]/).pop();
} else {
detail.className = 'res-err';
detail.textContent = r.error || 'Unknown error';
}
row.append(mark, net, detail);
resultsEl.appendChild(row);
}
if (ok > 0) {
outputDir = outEl.value;
openOutBtn.style.display = '';
}
}
});
</script>
</body>
</html>`;
}