Bladeren bron

feat(平台适配): 新增 AdPlatformAdapter 架构支持 Applovin/Unity/Playturbo/Google 多平台构建

- vite.config.js 根据 --mode 选择平台 adapter 并输出平台专用 HTML
- 新增 src/filler/ad-platform/ 适配层(types/adapters/helpers/storeUrls/current)
- 业务代码通过 adapter 接入生命周期(init/onResourcesLoaded/onGameStart/onGameEnd)
- CTA 改为 adapter.openStore(),Playturbo 产物不含 store URL/mraid/ExitApi/dapi
- 构建后移除 type="module"/crossorigin 以满足 Playturbo 本地文件扫描
- 新增 npm run build:applovin/unity/playturbo/mintegral/google/all
- dist/ 加入 .gitignore
guoziyun 3 weken geleden
bovenliggende
commit
791e456865

+ 1 - 1
.gitignore

@@ -5,7 +5,7 @@ public/bower/
 public/data
 public/app
 build
-dist/index.html
+dist/
 data/
 .DS_Store
 *.log

+ 38 - 18
README.md

@@ -101,8 +101,8 @@ https://gemini.google.com/share/8340c20dd2d1
 | 渲染引擎     | WebGL 2(自研,无外部 GL 框架)                     |
 | 构建工具     | Vite 5 + TypeScript 5                               |
 | 打包插件     | vite-plugin-singlefile(输出单文件 HTML)           |
-| 广告生命周期 | MRAID 2.0 + Unity DAPI                              |
-| 跳转优先级   | `mraid.open()` → `ExitApi.exit()` → `window.open()` |
+| 广告平台适配 | AdPlatformAdapter(Applovin / Unity / Playturbo-Mintegral / Google) |
+| 单文件产物   | 默认与各平台 profile 均输出自包含 HTML              |
 
 ## 项目结构
 
@@ -111,7 +111,7 @@ src/
   filler/         # 广告主逻辑
     index.ts      # 入口,整合加载/CTA/平台初始化
     cta.ts        # CTA 按钮点击 + 高亮动画
-    mraid.ts      # 平台生命周期适配(MRAID / Unity DAPI)
+    ad-platform/  # 平台 Adapter;构建时通过 Vite alias 选择目标平台
     FillerScene.ts# WebGL 填色场景
     FingerHint.ts # 指引手指 DOM overlay
     Loader.ts     # 资源加载
@@ -120,7 +120,12 @@ assets/
   res/            # 填色资源包(config.json + 图片)
   css/            # 样式
 dist/
-  index.html      # 最终产物,单文件自包含 HTML(~985 KB)
+  index.html                 # 默认产物
+  applovin/applovin.html     # Applovin 产物
+  unity/unity.html           # Unity 产物
+  playturbo/playturbo.html   # Playturbo 产物
+  mintegral/mintegral.html   # Mintegral 产物
+  google/google.html         # Google 产物
 ```
 
 ## 开发调试
@@ -151,35 +156,47 @@ http://42.193.231.145:5173
 
 ## 构建
 
+本项目保持一套业务源码,通过 Vite `--mode` 在构建期选择平台 Adapter,并输出平台专用单文件 HTML。默认构建使用 Google-like adapter,输出 `dist/index.html`。
+
 ```bash
-npm run build
+npm run build           # dist/index.html
+npm run build:applovin  # dist/applovin/applovin.html
+npm run build:unity     # dist/unity/unity.html
+npm run build:playturbo # dist/playturbo/playturbo.html
+npm run build:mintegral # dist/mintegral/mintegral.html
+npm run build:google    # dist/google/google.html
+npm run build:all       # 依次输出以上全部产物
 ```
 
-输出文件:`dist/index.html`(单文件,所有 JS/CSS/图片/字体均内联)
+所有产物均为单文件自包含 HTML,JS/CSS/图片/字体均内联。构建后会移除 Vite 单文件产物中的 `type="module"` 和 `crossorigin` 属性,以满足 Playturbo 对本地 HTML 文件的扫描要求。
 
 | 指标         | 数值                 |
 | ------------ | -------------------- |
-| 文件大小     | ~985 KB              |
-| Gzip 大小    | ~641 KB              |
+| 文件大小     | ~1.02 MB             |
+| Gzip 大小    | ~673 KB              |
 | 平台大小限制 | 5 MB(各平台均满足) |
 
-> **注意**:构建前请确认 `src/filler/cta.ts` 中的 `STORE_URL_IOS` / `STORE_URL_ANDROID`:
+平台选择由 `vite.config.js` 中的 `platformBuilds` 控制:`playturbo` 与 `mintegral` 都使用 Playturbo adapter,但输出到不同目录和文件名。当前不需要 `.env.applovin` / `.env.unity` 等环境文件。
+
+> **注意**:非 Playturbo/Mintegral 平台的 Store 链接集中在 `src/filler/ad-platform/adapters/storeUrls.ts`:
 >
 > - **测试阶段**:使用 PBN 落地页(当前默认),避免产生无效转化
 > - **正式上线**:换回自己 app 的 Store 链接(文件内有注释说明)
+>
+> Playturbo/Mintegral 产物不会内置 Store URL,CTA 只调用平台要求的 `window.install()`。
 
 ## 多平台测试
 
 ### 测试前准备
 
-1. 执行 `npm run build`,确认 `dist/index.html` 为最新产物
-2. 检查 `cta.ts` 中的落地页 URL 符合当前测试目的
+1. 根据目标平台执行对应构建命令,或执行 `npm run build:all` 生成全部产物
+2. 检查 `src/filler/ad-platform/adapters/storeUrls.ts` 中的落地页 URL 符合当前测试目的
 
 ### Applovin
 
 **预览工具**:https://p.applov.in/playablePreview?create=1
 
-1. 打开预览工具,上传 `dist/index.html`
+1. 执行 `npm run build:applovin`,打开预览工具,上传 `dist/applovin/applovin.html`
 2. 检查项:
    - [ ] 广告正常加载,WebGL 填色可交互
    - [ ] CTA 按钮可点击(Applovin 通过 `ExitApi.exit()` 关闭广告,落地页由平台后台配置)
@@ -190,7 +207,7 @@ npm run build
 
 **规范文档**:https://docs.unity.com/zh-cn/grow/acquire/creatives/playable/specifications
 
-1. 在 Unity Creative 后台上传 `dist/index.html`
+1. 执行 `npm run build:unity`,在 Unity Creative 后台上传 `dist/unity/unity.html`
 2. 检查项:
    - [ ] `dapi.gameReady()` 被正确调用(可在控制台确认)
    - [ ] CTA 跳转正常(Unity 使用 MRAID `mraid.open(url)` 跳转)
@@ -200,7 +217,7 @@ npm run build
 
 **规范文档**:https://support.google.com/google-ads/answer/9981650?hl=en#_HTML
 
-1. 使用 Google Web Designer 或 HTML5 验证工具检验
+1. 执行 `npm run build:google`,使用 Google Web Designer 或 HTML5 验证工具检验 `dist/google/google.html`
 2. 检查项:
    - [ ] 无外部网络请求(所有资源均已内联)
    - [ ] 文件大小满足限制(≤5 MB)
@@ -211,11 +228,14 @@ npm run build
 
 **规范文档**:https://www.playturbo.com/review/doc
 
-1. 按平台文档要求上传或提交审核
+1. 执行 `npm run build:playturbo` 或 `npm run build:mintegral`,按平台文档要求上传对应产物
 2. 检查项:
-   - [ ] MRAID 初始化流程正常
+   - [ ] 资源加载完成后调用 `window.gameReady()`
+   - [ ] 结束流程调用 `window.gameEnd()`
+   - [ ] CTA 只调用 `window.install()`,不主动 `window.open` 或跳 Store URL
+   - [ ] 产物不包含 `type="module"`、`crossorigin`、`import`、`export`
+   - [ ] 暴露 `window.gameStart` / `window.gameClose`
    - [ ] 音频在用户首次交互后解锁(iOS 限制)
-   - [ ] CTA 跳转正常
 
 ### 真机测试
 
@@ -241,7 +261,7 @@ A: 检查 WebGL context 是否初始化成功,Console 有无报错。
 A: iOS 要求用户交互后才能播放音频,代码已有 unlock 逻辑,确认首次点击后音效正常即可。
 
 **Q: CTA 点击没有跳转?**
-A: 各平台跳转机制不同——Applovin 由平台后台控制落地页;MRAID 环境下用 `mraid.open(url)`;其余环境用 `window.open(url)`
+A: 各平台跳转机制不同:Applovin 优先用 `ExitApi.exit()`;Unity/通用 MRAID 环境用 `mraid.open(url)`;Google-like 环境用 `window.open(url)`;Playturbo/Mintegral 只允许调用 `window.install()`,不应主动跳 Store URL
 
 **Q: 构建产物超过平台大小限制?**
 A: 检查 `assets/res/` 下的图片资源,压缩大尺寸图片后重新构建。

+ 47 - 8
agent.md

@@ -16,6 +16,7 @@
 | 动效/完成态 | js-confetti、Canvas 粒子爆破(自研 `Explosion`) |
 | 音效        | Web Audio API via HTMLAudioElement               |
 | 外部依赖    | js-confetti(jszip 已移除,playable 不需要)     |
+| 平台适配    | AdPlatformAdapter(Applovin / Unity / Playturbo-Mintegral / Google) |
 
 ### 1.2 文件树职责
 
@@ -46,6 +47,10 @@ src/
     LoadingController.ts # Loading overlay 显示/隐藏
     Audio.ts        # 音效播放器
     explosion.ts    # 粒子爆破动画
+    ad-platform/    # 广告平台适配层
+      types.ts      # Adapter 接口、平台类型、window SDK 类型声明
+      current.ts    # 默认 Google adapter;构建时由 Vite alias 替换为目标平台 adapter
+      adapters/     # applovin / unity / playturbo / google 实现
   utils/
     random.ts
 ```
@@ -112,8 +117,9 @@ CSS 回调 (cssAdjustProgress / cssColorDone / cssOnFinish)
 | 文件格式     | 单个自包含 HTML(所有 JS/CSS/图片 Base64 内联)            |
 | 文件大小     | ≤ 5 MB(Google)/ ≤ 5 MB(Unity)/ ≤ 3 MB(Applovin 建议) |
 | 外部请求     | **禁止**(Google/Applovin:所有资源必须内联)              |
-| HTTPS 重定向 | CTA 点击必须跳转到 store 链接(mraid.open / window.open)  |
+| HTTPS 重定向 | 通用平台 CTA 点击跳转 store;Playturbo/Mintegral 只能调用 `window.install()` |
 | MRAID        | Applovin/Unity 需支持 MRAID 2.0(mraid.js 注入)           |
+| 平台生命周期 | Unity 调 `dapi.gameReady()`;Playturbo 调 `gameReady/gameEnd` 并暴露 `gameStart/gameClose` |
 | 帧率         | 建议 60fps,不得阻塞主线程                                 |
 | 可玩时长     | 通常 15-60 秒内有 CTA                                      |
 | 广告标识     | 需显示"Ad"或"Sponsored"字样(部分平台)                    |
@@ -152,9 +158,9 @@ CSS 回调 (cssAdjustProgress / cssColorDone / cssOnFinish)
 
 ---
 
-### 阶段 2:CTA 与广告生命周期 ✅ 已完成(2026-05
+### 阶段 2:CTA 与广告生命周期 ✅ 已完成(2026-06 更新为 AdPlatformAdapter
 
-**目标**:符合各平台规范的 CTA 流程,点击跳转 App Store / Google Play
+**目标**:通过统一 AdPlatformAdapter 接入各平台 CTA 与生命周期 API,按构建 profile 输出平台专用 HTML
 
 **任务拆解**:
 
@@ -162,8 +168,8 @@ CSS 回调 (cssAdjustProgress / cssColorDone / cssOnFinish)
 | --- | ----------------------------------------- | ------------------------------------- | ---- |
 | 2.1 | 新增 CTA 按钮 UI(常驻 + 完成后放大高亮) | `index.html` / `assets/css/tools.css` | ✅   |
 | 2.2 | CTA 点击跳转逻辑                          | `src/filler/cta.ts`                   | ✅   |
-| 2.3 | MRAID 2.0 适配层                          | `src/filler/mraid.ts`                 | ✅   |
-| 2.4 | gameReady / 平台初始化事件                | `src/filler/index.ts`                 | ✅   |
+| 2.3 | 平台 Adapter 层                           | `src/filler/ad-platform/*`            | ✅   |
+| 2.4 | gameReady / gameEnd / 平台初始化事件      | `src/filler/index.ts`                 | ✅   |
 | 2.5 | 完成流程强化 CTA(宣传界面出现后高亮)    | `src/filler/index.ts`                 | ✅   |
 | 2.6 | "Ad"标签(右上角半透明徽章)              | `index.html` / `assets/css/tools.css` | ✅   |
 
@@ -256,7 +262,7 @@ CSS 回调 (cssAdjustProgress / cssColorDone / cssOnFinish)
 
 **选择**:Vite build 时通过 `vite-plugin-singlefile` 将所有 JS/CSS inline,图片/音效在 TS 模块中以 Base64 字符串形式维护(构建前由脚本生成 `src/filler/assetData.ts`)。
 
-**理由**:各平台明确要求单文件自包含,Vite 原生 asset inline 在 HTML 中仍产生多个文件,plugin-singlefile 是最少侵入的方案。
+**理由**:各平台明确要求单文件自包含,Vite 原生 asset inline 在 HTML 中仍产生多个文件,plugin-singlefile 是最少侵入的方案。Playturbo 会扫描本地 HTML 并拒绝 `type="module"` / `crossorigin` / ESM import-export,因此 `vite.config.js` 的 finalize plugin 会在构建后把内联 script/style 改成普通标签。
 
 ### D2:不引入 Vue/React
 
@@ -266,9 +272,38 @@ CSS 回调 (cssAdjustProgress / cssColorDone / cssOnFinish)
 
 ### D3:MRAID 适配策略
 
-**选择**:在 `src/filler/mraid.ts` 实现轻量适配层,detect window.mraid 存在再调用,否则 fallback `window.open`
+**选择**:MRAID 仅作为平台 Adapter 的底层能力,不再由业务代码直接调用
 
-**理由**:广告可能运行在不同平台 WebView,统一一套代码,运行时 feature detect。
+**理由**:不同平台对 CTA 和生命周期 API 的要求不同,单纯运行时 feature detect 容易与平台审核规则冲突。
+
+### D4:AdPlatformAdapter 平台适配架构
+
+**选择**:保持一套填色玩法业务源码,平台差异集中在 `src/filler/ad-platform/`,通过 `vite.config.js` 根据 `--mode` 配置 Vite alias,将 `./ad-platform/current` 替换为 Applovin / Unity / Playturbo-Mintegral / Google adapter。
+
+**理由**:Playturbo/Mintegral 要求 `window.install/gameReady/gameEnd` 且禁止自跳转,与通用 MRAID / `window.open` 策略冲突;分平台产物比单一万能 HTML 更稳。
+
+**平台行为矩阵**:
+
+| 平台 | Ready | End | CTA | Loading |
+| --- | --- | --- | --- | --- |
+| Applovin | MRAID custom close setup | no-op | `ExitApi.exit()` → `mraid.open(url)` → `window.open(url)` | 保留自定义 loading |
+| Unity | `dapi.gameReady()` | no-op | `mraid.open(url)` → `window.open(url)` | 保留自定义 loading |
+| Playturbo/Mintegral | `window.gameReady()` | `window.gameEnd()` | 只调用 `window.install()` | 不主动显示自定义 loading |
+| Google | no-op | no-op | `window.open(url, "_blank")` | 保留自定义 loading |
+
+**构建命令**:
+
+```bash
+npm run build           # dist/index.html
+npm run build:applovin  # dist/applovin/applovin.html
+npm run build:unity     # dist/unity/unity.html
+npm run build:playturbo # dist/playturbo/playturbo.html
+npm run build:mintegral # dist/mintegral/mintegral.html
+npm run build:google    # dist/google/google.html
+npm run build:all       # 依次输出默认与各平台产物
+```
+
+**约束**:业务代码不得直接调用平台全局 API,只能通过 Adapter 的 `init/onResourcesLoaded/onGameStart/onGameEnd/openStore`。
 
 ---
 
@@ -318,6 +353,10 @@ CSS 回调 (cssAdjustProgress / cssColorDone / cssOnFinish)
 | MRAID 版本差异导致 CTA 无效                     | 中             | P1   | 实现多版本 detect + fallback                       |
 | 音效在 iOS Safari 需用户手势触发                | 高(已知问题) | P2   | 首次点击上色时解锁 AudioContext                    |
 | 各平台预览工具对 inline base64 大文件的处理差异 | 低             | P1   | 提交前各平台逐一测试                               |
+| Playturbo 拒绝 `type="module"` / `crossorigin` | 高(已命中)   | P1   | 构建后 finalize HTML,移除 script/style 上的 module/crossorigin 属性 |
+| Playturbo 拒绝自定义 loading DOM                | 中             | P1   | Playturbo profile 不主动显示 loading;如 QA 仍拒绝,再做 HTML transform 移除 DOM |
+| 平台 SDK 注入 API 差异导致分支误判              | 中             | P1   | 使用构建 profile 固定平台策略,避免依赖运行时猜测   |
+| 平台生命周期重复触发                            | 中             | P2   | Adapter 内部对 ready/start/end 做幂等保护           |
 
 ---
 

+ 7 - 1
package.json

@@ -5,7 +5,13 @@
   "main": "index.js",
   "scripts": {
     "dev": "npx vite --open --cors --host 0.0.0.0",
-    "build": "npx vite build"
+    "build": "npx vite build",
+    "build:applovin": "npx vite build --mode applovin",
+    "build:unity": "npx vite build --mode unity",
+    "build:playturbo": "npx vite build --mode playturbo",
+    "build:mintegral": "npx vite build --mode mintegral",
+    "build:google": "npx vite build --mode google",
+    "build:all": "npm run build && npm run build:applovin && npm run build:unity && npm run build:playturbo && npm run build:mintegral && npm run build:google"
   },
   "author": "",
   "license": "ISC",

+ 46 - 0
src/filler/ad-platform/adapters/applovin.ts

@@ -0,0 +1,46 @@
+import { AdPlatformAdapter } from "../types";
+import { openWithMraidOrWindow, setupMraidCustomClose } from "./helpers";
+import { getStoreUrl } from "./storeUrls";
+
+export function createApplovinAdapter(): AdPlatformAdapter {
+  let started = false;
+  let ended = false;
+
+  return {
+    platform: "applovin",
+
+    init() {
+      setupMraidCustomClose();
+    },
+
+    onResourcesLoaded() {},
+
+    onGameStart() {
+      if (started) return;
+      started = true;
+    },
+
+    onGameEnd() {
+      if (ended) return;
+      ended = true;
+    },
+
+    openStore() {
+      if (window.ExitApi) {
+        window.ExitApi.exit();
+        return;
+      }
+      openWithMraidOrWindow(getStoreUrl());
+    },
+
+    shouldUseCustomLoading() {
+      return true;
+    },
+
+    shouldShowAdBadge() {
+      return true;
+    },
+  };
+}
+
+export const adPlatform = createApplovinAdapter();

+ 39 - 0
src/filler/ad-platform/adapters/google.ts

@@ -0,0 +1,39 @@
+import { AdPlatformAdapter } from "../types";
+import { getStoreUrl } from "./storeUrls";
+
+export function createGoogleAdapter(): AdPlatformAdapter {
+  let started = false;
+  let ended = false;
+
+  return {
+    platform: "google",
+
+    init() {},
+
+    onResourcesLoaded() {},
+
+    onGameStart() {
+      if (started) return;
+      started = true;
+    },
+
+    onGameEnd() {
+      if (ended) return;
+      ended = true;
+    },
+
+    openStore() {
+      window.open(getStoreUrl(), "_blank");
+    },
+
+    shouldUseCustomLoading() {
+      return true;
+    },
+
+    shouldShowAdBadge() {
+      return true;
+    },
+  };
+}
+
+export const adPlatform = createGoogleAdapter();

+ 21 - 0
src/filler/ad-platform/adapters/helpers.ts

@@ -0,0 +1,21 @@
+export function setupMraidCustomClose(): void {
+  if (!window.mraid) return;
+
+  const onReady = () => {
+    window.mraid!.useCustomClose(false);
+  };
+
+  if (window.mraid.getState() === "loading") {
+    window.mraid.addEventListener("ready", onReady);
+  } else {
+    onReady();
+  }
+}
+
+export function openWithMraidOrWindow(url: string): void {
+  if (window.mraid) {
+    window.mraid.open(url);
+    return;
+  }
+  window.open(url, "_blank");
+}

+ 49 - 0
src/filler/ad-platform/adapters/playturbo.ts

@@ -0,0 +1,49 @@
+import { AdPlatformAdapter } from "../types";
+
+export function createPlayturboAdapter(): AdPlatformAdapter {
+  let ready = false;
+  let started = false;
+  let ended = false;
+
+  const adapter: AdPlatformAdapter = {
+    platform: "playturbo",
+
+    init() {
+      window.gameStart = () => adapter.onGameStart();
+      window.gameClose = () => {};
+    },
+
+    onResourcesLoaded() {
+      if (ready) return;
+      ready = true;
+      window.gameReady?.();
+    },
+
+    onGameStart() {
+      if (started) return;
+      started = true;
+    },
+
+    onGameEnd() {
+      if (ended) return;
+      ended = true;
+      window.gameEnd?.();
+    },
+
+    openStore() {
+      window.install?.();
+    },
+
+    shouldUseCustomLoading() {
+      return false;
+    },
+
+    shouldShowAdBadge() {
+      return true;
+    },
+  };
+
+  return adapter;
+}
+
+export const adPlatform = createPlayturboAdapter();

+ 15 - 0
src/filler/ad-platform/adapters/storeUrls.ts

@@ -0,0 +1,15 @@
+/** App Store / Google Play 落地页链接,按需替换 */
+// 正式上线时换回自己的 app:
+// const STORE_URL_IOS = "https://apps.apple.com/app/id1575480118";
+// const STORE_URL_ANDROID = "https://play.google.com/store/apps/details?id=com.pcoloring.art.puzzle.color.by.number";
+
+// 测试期间使用 PBN,避免产生无效转化
+const STORE_URL_IOS =
+  "https://apps.apple.com/gb/app/paint-by-number-coloring-games/id1420058690";
+const STORE_URL_ANDROID =
+  "https://play.google.com/store/apps/details?id=com.oakever.paintbynumber";
+
+export function getStoreUrl(): string {
+  if (/android/i.test(navigator.userAgent)) return STORE_URL_ANDROID;
+  return STORE_URL_IOS;
+}

+ 56 - 0
src/filler/ad-platform/adapters/unity.ts

@@ -0,0 +1,56 @@
+import { AdPlatformAdapter } from "../types";
+import { openWithMraidOrWindow, setupMraidCustomClose } from "./helpers";
+import { getStoreUrl } from "./storeUrls";
+
+export function createUnityAdapter(): AdPlatformAdapter {
+  let ready = false;
+  let started = false;
+  let ended = false;
+
+  function notifyReady() {
+    if (ready) return;
+    ready = true;
+    window.dapi?.gameReady();
+  }
+
+  return {
+    platform: "unity",
+
+    init() {
+      setupMraidCustomClose();
+    },
+
+    onResourcesLoaded() {
+      if (!window.dapi) return;
+      if (window.dapi.isReady()) {
+        notifyReady();
+      } else {
+        window.dapi.addEventListener("ready", notifyReady);
+      }
+    },
+
+    onGameStart() {
+      if (started) return;
+      started = true;
+    },
+
+    onGameEnd() {
+      if (ended) return;
+      ended = true;
+    },
+
+    openStore() {
+      openWithMraidOrWindow(getStoreUrl());
+    },
+
+    shouldUseCustomLoading() {
+      return true;
+    },
+
+    shouldShowAdBadge() {
+      return true;
+    },
+  };
+}
+
+export const adPlatform = createUnityAdapter();

+ 4 - 0
src/filler/ad-platform/current.ts

@@ -0,0 +1,4 @@
+import { createGoogleAdapter } from "./adapters/google";
+
+export const adPlatform = createGoogleAdapter();
+export type { AdPlatformAdapter } from "./types";

+ 41 - 0
src/filler/ad-platform/types.ts

@@ -0,0 +1,41 @@
+export type AdPlatform = "applovin" | "unity" | "playturbo" | "mintegral" | "google";
+
+export interface AdPlatformAdapter {
+  readonly platform: AdPlatform;
+  init(): void;
+  onResourcesLoaded(): void;
+  onGameStart(): void;
+  onGameEnd(): void;
+  openStore(): void;
+  shouldUseCustomLoading(): boolean;
+  shouldShowAdBadge(): boolean;
+}
+
+export interface Mraid {
+  getState(): string;
+  open(url: string): void;
+  useCustomClose(use: boolean): void;
+  addEventListener(event: string, cb: () => void): void;
+  removeEventListener(event: string, cb: () => void): void;
+}
+
+declare global {
+  interface Window {
+    mraid?: Mraid;
+    ExitApi?: { exit: () => void };
+    dapi?: {
+      gameReady: () => void;
+      isReady: () => boolean;
+      addEventListener: (event: string, cb: () => void) => void;
+    };
+    install?: () => void;
+    gameReady?: () => void;
+    gameEnd?: () => void;
+    gameRetry?: () => void;
+    gameStart?: () => void;
+    gameClose?: () => void;
+    HttpAPI?: {
+      sendPoint?: (payload: string) => void;
+    };
+  }
+}

+ 3 - 20
src/filler/cta.ts

@@ -6,34 +6,17 @@
  * - 点击后通过 mraid 适配层跳转 Store
  */
 
-import { openStoreUrl } from "./mraid";
-
-/** App Store / Google Play 落地页链接,按需替换 */
-// 正式上线时换回自己的 app:
-// const STORE_URL_IOS = "https://apps.apple.com/app/id1575480118";
-// const STORE_URL_ANDROID = "https://play.google.com/store/apps/details?id=com.pcoloring.art.puzzle.color.by.number";
-
-// 测试期间使用 PBN,避免产生无效转化
-const STORE_URL_IOS =
-  "https://apps.apple.com/gb/app/paint-by-number-coloring-games/id1420058690";
-const STORE_URL_ANDROID =
-  "https://play.google.com/store/apps/details?id=com.oakever.paintbynumber";
-
-function getStoreUrl(): string {
-  const ua = navigator.userAgent;
-  if (/android/i.test(ua)) return STORE_URL_ANDROID;
-  return STORE_URL_IOS; // iOS / 桌面 fallback
-}
+import { AdPlatformAdapter } from "./ad-platform/types";
 
 /**
  * 初始化 CTA 按钮:绑定点击事件。
  * 应在 DOM ready 后调用一次。
  */
-export function initCta(): void {
+export function initCta(adapter: AdPlatformAdapter): void {
   const btn = document.getElementById("cta-btn");
   if (!btn) return;
   btn.addEventListener("click", () => {
-    openStoreUrl(getStoreUrl());
+    adapter.openStore();
   });
 }
 

+ 15 - 5
src/filler/index.ts

@@ -26,7 +26,7 @@ import { LoadingController } from "./LoadingController";
 import { FingerHint } from "./FingerHint";
 import JSConfetti from "js-confetti";
 import { initCta, ctaHighlight } from "./cta";
-import { initAdPlatform } from "./mraid";
+import { adPlatform } from "./ad-platform/current";
 
 // 静态导入资源,Vite 构建时会将它们转为 data URI 内联进 HTML
 import configRaw from "/assets/res/6a18f7d9957ac783bad75479/config.json?raw";
@@ -41,7 +41,9 @@ import coloringPagesUrl from "/assets/img/coloring-pages.png?url";
 import slogonUrl from "/assets/img/slogon.png?url";
 
 document.body.onload = function () {
-  initCta();
+  document.body.dataset.adPlatform = adPlatform.platform;
+  adPlatform.init();
+  initCta(adPlatform);
   init();
 };
 
@@ -62,7 +64,9 @@ async function init() {
     fadeDuration: 300, // 淡出动画300ms
   });
 
-  loadingController.show();
+  if (adPlatform.shouldUseCustomLoading()) {
+    loadingController.show();
+  }
 
   let canvas = document.querySelector("#canvas") as HTMLCanvasElement;
   let gl = canvas.getContext("webgl2", {
@@ -103,8 +107,10 @@ async function init() {
 
   let resource = await loadResource();
 
-  loadingController.hide();
-  initAdPlatform();
+  if (adPlatform.shouldUseCustomLoading()) {
+    loadingController.hide();
+  }
+  adPlatform.onResourcesLoaded();
 
   // 设置固定顶栏 logo(图标 + 文字)
   (document.getElementById("app-logo") as HTMLImageElement).src = logoUrl;
@@ -128,10 +134,12 @@ async function init() {
     taskList,
     {
       onFillFailed() {
+        adPlatform.onGameStart();
         console.log("填充失败");
         fingerHint?.onUserInteraction();
       },
       onFillSuccess() {
+        adPlatform.onGameStart();
         console.log("填充成功");
         fingerHint?.onUserInteraction();
         if (fillerData.config.settings.vibrate) {
@@ -157,6 +165,7 @@ async function init() {
       },
       onFinish() {
         fingerHint?.stop();
+        adPlatform.onGameEnd();
         cssOnFinish(scene, workLayer, hintLayer, audio);
       },
     },
@@ -228,6 +237,7 @@ async function init() {
   workLayer.initTask();
 
   if (fillerData.data.coloredPercent >= 100) {
+    adPlatform.onGameEnd();
     cssOnFinish(scene, workLayer, hintLayer, null);
   } else {
     // 创建手指提示,全图未完成时启动引导

+ 76 - 10
vite.config.js

@@ -1,16 +1,82 @@
+const fs = require("fs");
+const path = require("path");
 const { defineConfig } = require("vite");
 const { viteSingleFile } = require("vite-plugin-singlefile");
 
-module.exports = defineConfig({
-  plugins: [viteSingleFile()],
-  build: {
-    // 所有资源内联到 HTML,目标单文件广告
-    assetsInlineLimit: 100 * 1024 * 1024, // 不限大小,全部内联
-    target: "es2017",
-    rollupOptions: {
-      input: {
-        main: "./index.html",
+const platformBuilds = {
+  applovin: { adapter: "applovin", output: "applovin" },
+  unity: { adapter: "unity", output: "unity" },
+  playturbo: { adapter: "playturbo", output: "playturbo" },
+  mintegral: { adapter: "playturbo", output: "mintegral" },
+  google: { adapter: "google", output: "google" },
+};
+
+function patchSingleFileHtml(htmlPath) {
+  if (!fs.existsSync(htmlPath)) return;
+  const html = fs
+    .readFileSync(htmlPath, "utf8")
+    .replace(/<script\s+type="module"\s+crossorigin>/g, "<script>")
+    .replace(/<script\s+crossorigin\s+type="module">/g, "<script>")
+    .replace(/<script\s+type="module">/g, "<script>")
+    .replace(/<script\s+crossorigin>/g, "<script>")
+    .replace(/<style\s+rel="stylesheet"\s+crossorigin>/g, "<style>")
+    .replace(/<style\s+crossorigin\s+rel="stylesheet">/g, "<style>")
+    .replace(/<style\s+crossorigin>/g, "<style>");
+  fs.writeFileSync(htmlPath, html);
+}
+
+function finalizeHtmlPlugin(outDir, fileName) {
+  return {
+    name: "finalize-html-output",
+    closeBundle() {
+      const indexPath = path.resolve(__dirname, outDir, "index.html");
+      const targetPath = path.resolve(__dirname, outDir, fileName);
+      if (fileName !== "index.html" && fs.existsSync(indexPath)) {
+        fs.renameSync(indexPath, targetPath);
+      }
+      patchSingleFileHtml(targetPath);
+    },
+  };
+}
+
+module.exports = defineConfig(({ mode }) => {
+  const platformBuild = platformBuilds[mode];
+  const adapter = platformBuild?.adapter || "google";
+  const output = platformBuild?.output;
+  const outDir = output ? `dist/${output}` : "dist";
+  const htmlFileName = output ? `${output}.html` : "index.html";
+
+  return {
+    plugins: [viteSingleFile(), finalizeHtmlPlugin(outDir, htmlFileName)],
+    resolve: {
+      alias: {
+        "./ad-platform/current": path.resolve(
+          __dirname,
+          `src/filler/ad-platform/adapters/${adapter}.ts`,
+        ),
+      },
+    },
+    build: {
+      // 所有资源内联到 HTML,目标单文件广告
+      assetsInlineLimit: 100 * 1024 * 1024, // 不限大小,全部内联
+      target: "es2017",
+      outDir,
+      emptyOutDir: true,
+      rollupOptions: {
+        input: {
+          [path.basename(htmlFileName, ".html")]: "./index.html",
+        },
+        output: {
+          entryFileNames: "assets/[name].js",
+          chunkFileNames: "assets/[name].js",
+          assetFileNames: (assetInfo) => {
+            if (assetInfo.name && assetInfo.name.endsWith(".html")) {
+              return htmlFileName;
+            }
+            return "assets/[name][extname]";
+          },
+        },
       },
     },
-  },
+  };
 });