Cocos Creator Facebook Playable Ad 单文件打包完全指南:Python 自动化脚本与资源内嵌实战

引言

Facebook Playable Ad(试玩广告)是一种让用户在广告中直接体验游戏片段的创新广告形式。与传统视频广告不同,Playable Ad 要求所有资源(HTML、JavaScript、图片、音频)打包成一个不超过 2MB 的 HTML 文件。这对于使用 Cocos Creator 开发的游戏来说,需要将常规的 Web 构建产物(包含多个独立文件)合并为单个自包含的 HTML 文件。本文将详细介绍如何使用 Python 脚本自动化完成这一转换过程,包括资源内嵌、代码合并、base64 编码等关键技术。

Playable Ad 技术要求

Facebook 官方规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌─────────────────────────────────────────────────────────────────────┐
│ Facebook Playable Ad 技术限制 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 文件限制 │
│ • 格式:单 HTML 文件 │
│ • 大小:不超过 2MB(压缩后) │
│ • 编码:UTF-8 │
│ │
│ 资源内嵌 │
│ • JavaScript:内嵌到 <script> 标签 │
│ • CSS:内嵌到 <style> 标签 │
│ • 图片:base64 编码内嵌 │
│ • 音频:base64 编码内嵌 │
│ │
│ API 要求 │
│ • 必须调用 FbPlayableAd.onCTAClick() 响应下载按钮 │
│ • 必须包含游戏玩法和 CTA(Call To Action)按钮 │
│ │
│ 外部限制 │
│ • 不能有外部资源请求 │
│ • 不能使用 CDN 加载 │
│ • 不能使用 localStorage 等持久化存储 │
│ │
└─────────────────────────────────────────────────────────────────────┘

Cocos Creator 原始构建产物

1
2
3
4
5
6
7
8
9
10
11
12
13
build/
└── web-mobile/
├── index.html # 主 HTML 文件
├── main.js # 启动脚本
├── cocos2d-js-min.js # 引擎代码(约 1MB+)
├── src/
│ ├── settings.js # 游戏配置
│ └── project.js # 游戏逻辑代码
├── res/ # 游戏资源
│ ├── import/ # 导入资源
│ ├── raw-assets/ # 原始资源
│ └── ...
└── ...

注意:原始构建产物包含数百个独立文件,无法直接作为 Playable Ad 提交。

打包方案设计

核心思路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
┌─────────────────────────────────────────────────────────────────────┐
│ Playable Ad 打包流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 输入:Cocos Creator 构建产物 │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ index.html│ │ main.js │ │engine.js │ │project.js │ │
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │ │ │
│ └───────────────┴───────────────┴───────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ 合并 JS 代码 │ │
│ │ 替换占位符 │ │
│ └────────┬──────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ .png │ │ .jpg │ │ .mp3/.ogg│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └─────────────┴─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Base64 编码内嵌 │ │
│ │ 生成资源映射表 │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ 输出:单个 HTML │ │
│ │ 大小 < 2MB │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

Python 打包脚本实现

脚本架构

1
2
3
4
playable-compile/
├── compile.py # 主打包脚本
├── index.html # 模板 HTML 文件
└── main.js # 修改后的启动脚本

compile.py 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
#!/usr/bin/python
# -*- coding: UTF-8 -*-

"""
Cocos Creator Facebook Playable Ad 打包脚本
功能:将 Cocos Creator Web 构建产物打包为单个 HTML 文件
"""

from xml.dom.minidom import parse
import xml.dom.minidom
import json
import os
import time
import sys
import codecs
import cgi
import HTMLParser
import re
import base64
import zipfile

# 设置默认编码为 UTF-8
if sys.getdefaultencoding() != 'utf-8':
reload(sys)
sys.setdefaultencoding('utf-8')


class PlayableCompiler:
"""Playable Ad 编译器"""

def __init__(self):
# 项目根目录
self.root_dir = os.getcwd()
print("RootDir: " + self.root_dir)

# Cocos Creator 构建输出目录
self.build_dir = os.path.join(self.root_dir, 'build', 'web-mobile')

# 源文件路径
self.html_path = os.path.join(self.build_dir, 'index.html')
self.settings_path = os.path.join(self.build_dir, 'src', 'settings.js')
self.main_path = os.path.join(self.build_dir, 'main.js')
self.engine_path = os.path.join(self.build_dir, 'cocos2d-js-min.js')
self.project_path = os.path.join(self.build_dir, 'src', 'project.js')

# 资源目录
self.res_path = os.path.join(self.build_dir, 'res')

# 占位符(在 HTML 模板中标记需要替换的位置)
self.setting_match_key = '{#settings}'
self.main_match_key = '{#main}'
self.engine_match_key = '{#cocosengine}'
self.project_match_key = '{#project}'
self.res_map_match_key = '{#resMap}'

# 需要 base64 编码的文件类型
self.file_byte_list = ['.png', '.jpg', '.jpeg', '.mp3', '.ogg', '.wav', '.m4a']

# base64 前缀
self.base64_prefix = {
'.png': 'data:image/png;base64,',
'.jpg': 'data:image/jpeg;base64,',
'.jpeg': 'data:image/jpeg;base64,',
'.mp3': 'data:audio/mpeg;base64,',
'.ogg': 'data:audio/ogg;base64,',
'.wav': 'data:audio/wav;base64,',
'.m4a': 'data:audio/mp4;base64,'
}

# 资源映射表
self.res_map = {}

def read_file(self, file_path, chunk_size=1024*1024):
"""
读取文件内容
对于二进制文件(图片、音频)返回 base64 编码
对于文本文件返回字符串内容
"""
ext = os.path.splitext(file_path)[1].lower()

if ext in self.file_byte_list:
# 二进制文件:读取并转为 base64
with open(file_path, 'rb') as f:
data = f.read()
base64_str = base64.b64encode(data)
prefix = self.base64_prefix.get(ext, '')
return prefix + base64_str if prefix else base64_str
elif ext == '':
# 无扩展名的文件(可能是目录)
return None
else:
# 文本文件
with open(file_path, 'r') as f:
return f.read()

def write_file(self, path, data):
"""写入文件"""
with open(path, 'w') as f:
f.write(data)

def build_res_map(self, current_path, base_res_path):
"""
递归构建资源映射表
将所有资源文件转为 base64 并保存到映射表
"""
try:
file_list = os.listdir(current_path)
for file_name in file_list:
abs_path = os.path.join(current_path, file_name)

if os.path.isdir(abs_path):
# 递归处理子目录
self.build_res_map(abs_path, base_res_path)
elif os.path.isfile(abs_path):
# 处理文件
data = self.read_file(abs_path)
if data:
# 计算相对路径(在资源目录中的路径)
rel_path = 'res' + abs_path.replace(base_res_path, '')
self.res_map[rel_path] = data
print("资源已映射: " + rel_path)
except Exception as e:
print("构建资源映射出错: " + str(e))

def get_res_map_script(self):
"""生成资源映射表的 JavaScript 代码"""
res_str = "window.resMap = " + json.dumps(self.res_map)
return res_str

def compile(self):
"""执行打包"""
print("\n=== 开始打包 Playable Ad ===\n")

# 1. 读取 HTML 模板
print("1. 读取 HTML 模板...")
html_content = self.read_file(self.html_path)
if not html_content:
print("错误:无法读取 index.html")
return False

# 2. 读取并内嵌 settings.js
print("2. 内嵌 settings.js...")
settings_content = self.read_file(self.settings_path)
if settings_content:
html_content = html_content.replace(
self.setting_match_key,
settings_content,
1
)

# 3. 读取并内嵌 project.js
print("3. 内嵌 project.js...")
project_content = self.read_file(self.project_path)
if project_content:
html_content = html_content.replace(
self.project_match_key,
project_content,
1
)

# 4. 读取并内嵌 main.js
print("4. 内嵌 main.js...")
main_content = self.read_file(self.main_path)
if main_content:
html_content = html_content.replace(
self.main_match_key,
main_content,
1
)

# 5. 读取并内嵌引擎代码
print("5. 内嵌引擎代码...")
engine_content = self.read_file(self.engine_path)
if engine_content:
html_content = html_content.replace(
self.engine_match_key,
engine_content,
1
)

# 6. 构建资源映射表
print("6. 构建资源映射表...")
if os.path.exists(self.res_path):
self.build_res_map(self.res_path, self.res_path)
res_script = self.get_res_map_script()
html_content = html_content.replace(
self.res_map_match_key,
res_script,
1
)

# 7. 写入输出文件
print("7. 写入输出文件...")
output_path = os.path.join(self.build_dir, 'index.html')
self.write_file(output_path, html_content)

# 8. 统计文件大小
file_size = os.path.getsize(output_path)
file_size_mb = file_size / (1024 * 1024)
print("\n=== 打包完成 ===")
print("输出文件: " + output_path)
print("文件大小: %.2f MB (%.2f KB)" % (file_size_mb, file_size / 1024))

# 检查大小限制
if file_size_mb > 2:
print("\n警告:文件大小超过 2MB 限制!")
print("建议:压缩图片、减少资源或移除不必要的功能")
else:
print("✓ 文件大小符合 Facebook Playable Ad 要求")

return True


def main():
"""主函数"""
compiler = PlayableCompiler()
success = compiler.compile()

if success:
print("\n打包成功!")
return 0
else:
print("\n打包失败!")
return 1


if __name__ == '__main__':
sys.exit(main())

HTML 模板修改

修改后的 index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Playable Game</title>

<!-- 移动设备适配 -->
<meta name="viewport"
content="width=device-width,user-scalable=no,initial-scale=1, minimum-scale=1,maximum-scale=1"/>

<!-- iOS 全屏支持 -->
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
<meta name="format-detection" content="telephone=no"/>

<!-- WebKit 内核优化 -->
<meta name="renderer" content="webkit"/>
<meta name="force-rendering" content="webkit"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
<meta name="msapplication-tap-highlight" content="no"/>

<!-- 全屏支持 -->
<meta name="full-screen" content="yes"/>
<meta name="x5-fullscreen" content="true"/>
<meta name="360-fullscreen" content="true"/>

<style>
html {
-ms-touch-action: none;
}

body, canvas, div {
display: block;
outline: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-khtml-user-select: none;
}

body {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 0;
border: 0;
margin: 0;
cursor: default;
color: #888;
background-color: #333;
text-align: center;
font-family: Helvetica, Verdana, Arial, sans-serif;
display: flex;
flex-direction: column;
}

#Cocos2dGameContainer {
position: absolute;
margin: 0;
overflow: hidden;
left: 0px;
top: 0px;
display: -webkit-box;
-webkit-box-orient: horizontal;
-webkit-box-align: center;
-webkit-box-pack: center;
}

canvas {
background-color: rgba(0, 0, 0, 0);
}

/* 启动画面 */
#splash {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #171717 url(./splash.png) no-repeat center;
background-size: 40%;
}

.progress-bar {
background-color: #1a1a1a;
position: absolute;
left: 25%;
top: 80%;
height: 15px;
padding: 5px;
width: 50%;
border-radius: 5px;
box-shadow: 0 1px 5px #000 inset, 0 1px 0 #444;
}

.progress-bar span {
display: block;
height: 100%;
border-radius: 3px;
box-shadow: 0 1px 0 rgba(255, 255, 255, .5) inset;
transition: width .4s ease-in-out;
background-color: #34c2e3;
}
</style>
</head>
<body>
<canvas id="GameCanvas" oncontextmenu="event.preventDefault()" tabindex="0"></canvas>
<div id="splash">
<div class="progress-bar">
<span style="width: 0%"></span>
</div>
</div>

<!-- 资源映射表(将被内嵌) -->
<script type="text/javascript" charset="utf-8">
{#resMap}
</script>

<!-- 游戏配置(将被内嵌) -->
<script type="text/javascript" charset="utf-8">
{#settings}
</script>

<!-- 引擎代码(将被内嵌) -->
<script type="text/javascript" charset="utf-8">
{#cocosengine}
</script>

<!-- 游戏逻辑(将被内嵌) -->
<script type="text/javascript" charset="utf-8">
{#project}
</script>

<!-- 启动脚本(将被内嵌) -->
<script type="text/javascript" charset="utf-8">
{#main}
</script>
</body>
</html>

资源加载器改造

修改 main.js 的加载逻辑

Cocos Creator 的默认加载器会通过网络请求加载资源,需要修改为从 window.resMap 中读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
(function () {
'use strict';

function boot() {
var settings = window._CCSettings;
window._CCSettings = undefined;

if (!settings.debug) {
// 处理资源 UUID 映射
var uuids = settings.uuids;
var rawAssets = settings.rawAssets;
var assetTypes = settings.assetTypes;
var realRawAssets = settings.rawAssets = {};

for (var mount in rawAssets) {
var entries = rawAssets[mount];
var realEntries = realRawAssets[mount] = {};
for (var id in entries) {
var entry = entries[id];
var type = entry[1];
if (typeof type === 'number') {
entry[1] = assetTypes[type];
}
realEntries[uuids[id] || id] = entry;
}
}

var scenes = settings.scenes;
for (var i = 0; i < scenes.length; ++i) {
var scene = scenes[i];
if (typeof scene.uuid === 'number') {
scene.uuid = uuids[scene.uuid];
}
}

var packedAssets = settings.packedAssets;
for (var packId in packedAssets) {
var packedIds = packedAssets[packId];
for (var j = 0; j < packedIds.length; ++j) {
if (typeof packedIds[j] === 'number') {
packedIds[j] = uuids[packedIds[j]];
}
}
}
}

// ... 其他初始化代码 ...
}

// ========== 资源加载器改造 ==========

// Base64 转 Blob
function base64toBlob(base64, type) {
var bstr = atob(base64);
var n = bstr.length;
var u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: type });
}

// Base64 转 Uint8Array(用于 WebAudio)
function base64toArray(base64) {
var bstr = atob(base64);
var n = bstr.length;
var u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return u8arr;
}

// 加载 DOM Audio
function loadDomAudio(item, callback) {
var dom = document.createElement('audio');
dom.muted = false;

// 从 resMap 获取 base64 数据
var data = window.resMap[item.url.split("?")[0]];
data = base64toBlob(data, "audio/mpeg");

if (window.URL) {
dom.src = window.URL.createObjectURL(data);
} else {
dom.src = data;
}

var clearEvent = function () {
clearTimeout(timer);
dom.removeEventListener("canplaythrough", success, false);
dom.removeEventListener("error", failure, false);
};

var timer = setTimeout(function () {
if (dom.readyState === 0) {
failure();
} else {
success();
}
}, 8000);

var success = function () {
clearEvent();
item.element = dom;
callback(null, item.url);
};

var failure = function () {
clearEvent();
callback('load audio failure - ' + item.url, null);
};

dom.addEventListener("canplaythrough", success, false);
dom.addEventListener("error", failure, false);
}

// 加载 WebAudio
function loadWebAudio(item, callback) {
var context = cc.sys.__audioSupport.context;
if (!context) {
callback(new Error('Audio Downloader: no web audio context.'));
return;
}

// 从 resMap 获取数据
var data = window.resMap[item.url];
data = base64toArray(data);

if (data) {
context.decodeAudioData(data.buffer, function(buffer) {
item.buffer = buffer;
callback(null, buffer);
}, function() {
callback('decode error - ' + item.id, null);
});
} else {
callback('request error - ' + item.id, null);
}
}

// 加载图片
var arrayBufferHandler = function (item, callback) {
var data = window.resMap[item.url];
if (!data) {
callback(new Error('Resource not found: ' + item.url));
return;
}

var img = new Image();

function loadCallback() {
img.removeEventListener('load', loadCallback);
img.removeEventListener('error', errorCallback);
callback(null, img);
}

function errorCallback() {
img.removeEventListener('load', loadCallback);
img.removeEventListener('error', errorCallback);
callback(new Error('Load image failed: ' + item.url));
}

img.addEventListener('load', loadCallback);
img.addEventListener('error', errorCallback);
img.src = data;
};

// 加载 JSON
var jsonBufferHandler = function (item, callback) {
var str = window.resMap[item.url];
callback(null, str);
};

// 音频加载器
var audioBufferHandler = function (item, callback) {
var __audioSupport = cc.sys.__audioSupport;
var formatSupport = __audioSupport.format;

if (formatSupport.length === 0) {
return new Error('Audio not supported');
}

item.content = item.url;

if (!__audioSupport.WEB_AUDIO) {
loadDomAudio(item, callback);
} else {
loadWebAudio(item, callback);
}
};

// 注册自定义加载器
if (window.document) {
var splash = document.getElementById('splash');
if (splash) {
splash.style.display = 'block';
}

// 在引擎初始化完成后注册加载器
cc.loader.addDownloadHandlers({
'png': arrayBufferHandler,
'jpg': arrayBufferHandler,
'jpeg': arrayBufferHandler,
});

cc.loader.addDownloadHandlers({
'json': jsonBufferHandler,
});

cc.loader.addDownloadHandlers({
'mp3': audioBufferHandler,
'ogg': audioBufferHandler,
'wav': audioBufferHandler,
'm4a': audioBufferHandler
});

boot();
}
})();

添加 CTA 按钮回调

在游戏的结算界面添加下载按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// GameOver.js
const { ccclass, property } = cc._decorator;

@ccclass
export default class GameOver extends cc.Component {

@property(cc.Button)
playMoreBtn: cc.Button = null;

onLoad() {
// 绑定 CTA 按钮事件
this.playMoreBtn.node.on('click', this.onCTAClick, this);
}

/**
* CTA 按钮点击回调
* 调用 Facebook Playable Ad API
*/
onCTAClick() {
// 调用 Facebook API 打开应用商店
if (typeof FbPlayableAd !== 'undefined') {
FbPlayableAd.onCTAClick();
} else {
// 在非 Playable Ad 环境提供降级处理
console.log('CTA Clicked - redirect to store');
window.open('https://apps.apple.com/app/idxxxxxx', '_blank');
}
}
}

打包流程

完整打包步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 1. Cocos Creator 构建
# 打开 Cocos Creator → 项目 → 构建发布
# 选择 "Web Mobile" 平台
# 勾选 "MD5 Cache"(可选)
# 点击 "构建"

# 2. 运行打包脚本
cd playable-compile
python compile.py

# 3. 验证输出
ls -lh build/web-mobile/index.html
# 文件大小应小于 2MB

# 4. 本地测试
# 使用本地服务器测试
npx http-server build/web-mobile -p 8080

# 5. Facebook 测试工具
# 访问 https://www.facebook.com/business/tools/ads-preview/playable-ad/
# 上传生成的 index.html

性能优化建议

文件大小优化

优化项 方法 效果
图片压缩 tinypng.com 减少 60-80%
引擎裁剪 构建时选择 “引擎分离” 按需加载
音频压缩 使用 OGG 格式 减少 30-50%
代码压缩 UglifyJS 减少 40-60%
移除调试 关闭调试模式 减少 20%

关键配置

1
2
3
4
5
6
7
8
9
10
11
// Cocos Creator 构建配置
{
"platform": "web-mobile",
"actualPlatform": "web",
"template": "default",
"debug": false, // 关闭调试
"sourceMaps": false, // 关闭 source map
"embedWebDebugger": false, // 不嵌入调试器
"previewWidth": 1280,
"previewHeight": 720
}

常见问题

问题一:文件超过 2MB

1
2
3
4
5
6
7
# 在 compile.py 中添加压缩检查
def check_size(self, file_path, max_size_mb=2):
size_mb = os.path.getsize(file_path) / (1024 * 1024)
if size_mb > max_size_mb:
print("错误:文件大小 %.2fMB 超过 %dMB 限制" % (size_mb, max_size_mb))
return False
return True

问题二:资源加载失败

1
2
3
4
5
6
7
// 添加错误处理
if (!window.resMap[item.url]) {
console.warn('Resource not in resMap:', item.url);
// 返回占位资源或空资源
callback(null, null);
return;
}

问题三:音频无法播放

1
2
3
4
5
6
7
8
9
10
11
12
// 确保音频格式支持
var supportedFormats = ['.mp3', '.ogg'];
function getSupportedAudioUrl(url) {
for (var i = 0; i < supportedFormats.length; i++) {
var ext = supportedFormats[i];
var testUrl = url.replace(/\.[^.]+$/, ext);
if (window.resMap[testUrl]) {
return testUrl;
}
}
return url;
}

总结

Facebook Playable Ad 打包的核心要点:

  1. 单文件要求:所有资源必须内嵌到一个 HTML 文件中
  2. Base64 编码:图片和音频需要转为 base64 内嵌
  3. 资源映射:使用 window.resMap 存储所有资源
  4. 加载器改造:重写 cc.loader 的下载处理函数
  5. 大小限制:严格控制文件大小在 2MB 以内
  6. CTA 按钮:必须调用 FbPlayableAd.onCTAClick() 响应下载
  7. 自动化构建:使用 Python 脚本简化打包流程

通过合理的资源管理和自动化脚本,可以高效地将 Cocos Creator 游戏打包为符合 Facebook 规范的 Playable Ad。