Facebook Playable 广告制作与部署实战

背景

Playable 广告(试玩广告)让用户在下载前先体验游戏核心玩法。这种广告形式转化率挺高的,因为用户下载前就知道游戏是啥样的。这篇文章分享我用 Cocos Creator 制作 Facebook Playable 广告的经验,从打包到上线的完整流程。

Playable 广告概述

什么是 Playable 广告

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌─────────────────────────────────────────┐
│ Facebook Playable 广告 │
├─────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ │ 游戏试玩场景(15-30秒) │ │
│ │ │ │
│ │ • 核心玩法展示 │ │
│ │ • 简单操作引导 │ │
│ │ • 自动/手动触发 CTA │ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ │
│ [了解更多] ← 点击跳转到应用商店 │
│ │
└─────────────────────────────────────────┘

Playable 广告的优势

优势 说明
高转化率 用户体验后再下载,意愿更强
低卸载率 用户对游戏有明确预期
精准用户 吸引真正感兴趣的玩家
品牌展示 充分展示游戏特色

技术规范

项目 要求
文件格式 单个 HTML 文件
文件大小 不超过 2MB
加载时间 首屏 < 5秒
游戏时长 15-30秒体验
横竖屏 根据游戏类型选择

项目准备

1. 创建试玩场景

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
// PlayableScene.js
cc.Class({
extends: cc.Component,

properties: {
// 游戏核心玩法展示
gameDemo: cc.Node,
// CTA 按钮
ctaButton: cc.Button,
// 引导提示
guideLabel: cc.Label,
// 计时器
timerLabel: cc.Label
},

onLoad() {
// 初始化试玩场景
this.initPlayable();

// 设置 CTA 回调
this.ctaButton.node.on('click', this.onCTAClick, this);

// 自动触发 CTA(可选)
this.scheduleOnce(this.autoShowCTA, 15);
},

initPlayable() {
// 简化版核心玩法
// 1. 只展示核心机制
// 2. 简化操作
// 3. 快速正向反馈

this.showGuide();
},

showGuide() {
// 简单操作引导
this.guideLabel.string = "点击消除方块!";

// 3秒后隐藏引导
this.scheduleOnce(() => {
this.guideLabel.node.active = false;
}, 3);
},

autoShowCTA() {
// 自动显示 CTA
this.ctaButton.node.active = true;

// 脉冲动画吸引点击
this.ctaButton.node.runAction(cc.repeatForever(
cc.sequence(
cc.scaleTo(0.5, 1.1),
cc.scaleTo(0.5, 1.0)
)
));
},

onCTAClick() {
// 调用 Facebook Playable API
if (typeof FbPlayableAd !== 'undefined') {
FbPlayableAd.onCTAClick();
} else {
// 本地测试时打开商店链接
window.open('https://play.google.com/store/apps/details?id=com.yourgame');
}
}
});

2. 资源优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 资源加载优化
cc.Class({
extends: cc.Component,

onLoad() {
// 使用 Base64 内嵌小资源
this.loadEmbeddedResources();
},

loadEmbeddedResources() {
// 小图标、音效等使用 Base64 内嵌
// 大资源使用外部 CDN(在允许列表中)

// 示例:Base64 图片
const base64Image = 'data:image/png;base64,iVBORw0KGgo...';
cc.loader.load(base64Image, (err, texture) => {
if (!err) {
this.sprite.spriteFrame = new cc.SpriteFrame(texture);
}
});
}
});

打包工具

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
#!/usr/bin/env python
# -*- coding: UTF-8 -*-

"""
Cocos Creator Playable 广告打包工具
将 web-mobile 构建输出打包成单个 HTML 文件
"""

import os
import json
import base64
import sys
import re

def get_script_dir():
"""获取脚本所在目录"""
return os.path.dirname(os.path.abspath(__file__))

def read_file(file_path, mode='r'):
"""读取文件内容"""
try:
if 'b' in mode:
with open(file_path, 'rb') as f:
return f.read()
else:
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
except Exception as e:
print(f"Error reading {file_path}: {e}")
return None

def get_base64_prefix(ext):
"""获取 Base64 数据 URL 前缀"""
prefixes = {
'.png': 'data:image/png;base64,',
'.jpg': 'data:image/jpeg;base64,',
'.jpeg': 'data:image/jpeg;base64,',
'.gif': 'data:image/gif;base64,',
'.mp3': 'data:audio/mpeg;base64,',
'.ogg': 'data:audio/ogg;base64,',
'.wav': 'data:audio/wav;base64,',
'.mp4': 'data:video/mp4;base64,',
'.json': 'data:application/json;base64,',
'.ttf': 'data:font/ttf;base64,',
'.woff': 'data:font/woff;base64,'
}
return prefixes.get(ext.lower(), '')

def file_to_base64(file_path):
"""将文件转换为 Base64 字符串"""
try:
with open(file_path, 'rb') as f:
data = f.read()
base64_str = base64.b64encode(data).decode('utf-8')

ext = os.path.splitext(file_path)[1]
prefix = get_base64_prefix(ext)

return prefix + base64_str if prefix else base64_str
except Exception as e:
print(f"Error converting {file_path} to base64: {e}")
return None

def build_resource_map(res_dir):
"""构建资源映射表"""
res_map = {}

if not os.path.exists(res_dir):
print(f"Resource directory not found: {res_dir}")
return res_map

for root, dirs, files in os.walk(res_dir):
for file in files:
file_path = os.path.join(root, file)
rel_path = os.path.relpath(file_path, res_dir)
rel_path = 'res/' + rel_path.replace('\\', '/')

base64_data = file_to_base64(file_path)
if base64_data:
res_map[rel_path] = base64_data
print(f"Processed: {rel_path}")

return res_map

def create_playable_html(build_dir, output_file):
"""创建 Playable 广告 HTML"""

# 读取模板 HTML
template_path = os.path.join(get_script_dir(), 'template.html')
html_content = read_file(template_path)

if not html_content:
print("Error: template.html not found")
return False

# 读取并插入 settings.js
settings_path = os.path.join(build_dir, 'src/settings.js')
settings_js = read_file(settings_path)
if settings_js:
html_content = html_content.replace('{#settings}', settings_js)

# 读取并插入 main.js
main_path = os.path.join(build_dir, 'main.js')
main_js = read_file(main_path)
if main_js:
html_content = html_content.replace('{#main}', main_js)

# 读取并插入 cocos2d-js-min.js
engine_path = os.path.join(build_dir, 'cocos2d-js-min.js')
engine_js = read_file(engine_path)
if engine_js:
html_content = html_content.replace('{#cocosengine}', engine_js)

# 读取并插入 project.js
project_path = os.path.join(build_dir, 'src/project.js')
project_js = read_file(project_path)
if project_js:
html_content = html_content.replace('{#project}', project_js)

# 构建并插入资源映射
res_dir = os.path.join(build_dir, 'res')
res_map = build_resource_map(res_dir)
res_map_js = f'window.resMap = {json.dumps(res_map, ensure_ascii=False)};'
html_content = html_content.replace('{#resMap}', res_map_js)

# 写入输出文件
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html_content)

# 检查文件大小
file_size = os.path.getsize(output_file)
print(f"\nOutput: {output_file}")
print(f"Size: {file_size / 1024:.2f} KB")

if file_size > 2 * 1024 * 1024:
print("WARNING: File size exceeds 2MB limit!")

return True

def main():
"""主函数"""
# 默认构建目录
build_dir = os.path.join(get_script_dir(), 'build', 'web-mobile')
output_file = os.path.join(get_script_dir(), 'playable_ad.html')

# 支持命令行参数
if len(sys.argv) > 1:
build_dir = sys.argv[1]
if len(sys.argv) > 2:
output_file = sys.argv[2]

print(f"Building Playable Ad...")
print(f"Build dir: {build_dir}")
print(f"Output: {output_file}")
print("-" * 50)

if create_playable_html(build_dir, output_file):
print("\nBuild successful!")
else:
print("\nBuild failed!")
sys.exit(1)

if __name__ == '__main__':
main()

使用说明

1
2
3
4
5
6
7
8
# 1. 构建 Cocos Creator 项目
cocos creator --build "platform=web-mobile"

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

# 3. 或指定参数
python compile.py ./build/web-mobile ./output/playable.html

资源加载处理

修改 main.js 支持 Base64 资源

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
// 在 main.js 中添加以下代码

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

// 自定义资源加载器
if (window.document) {
const __audioSupport = cc.sys.__audioSupport;

// 图片加载
const imageHandler = (item, callback) => {
const data = window.resMap[item.url];
if (!data) {
callback(new Error(`Resource not found: ${item.url}`));
return;
}

const img = new Image();
img.onload = () => callback(null, img);
img.onerror = () => callback(new Error(`Failed to load: ${item.url}`));
img.src = data;
};

// 音频加载
const audioHandler = (item, callback) => {
const data = window.resMap[item.url];
if (!data) {
callback(new Error(`Resource not found: ${item.url}`));
return;
}

// 转换为 Blob URL
const blob = base64toBlob(data.split(',')[1], 'audio/mpeg');
const blobUrl = URL.createObjectURL(blob);

const audio = new Audio();
audio.src = blobUrl;

const timer = setTimeout(() => {
if (audio.readyState === 0) {
callback(new Error('Audio load timeout'));
} else {
callback(null, audio);
}
}, 8000);

audio.oncanplaythrough = () => {
clearTimeout(timer);
callback(null, audio);
};

audio.onerror = () => {
clearTimeout(timer);
callback(new Error('Audio load error'));
};
};

// JSON 加载
const jsonHandler = (item, callback) => {
const data = window.resMap[item.url];
if (!data) {
callback(new Error(`Resource not found: ${item.url}`));
return;
}

try {
const json = JSON.parse(data);
callback(null, json);
} catch (e) {
callback(new Error('JSON parse error'));
}
};

// 注册自定义加载器
cc.loader.addDownloadHandlers({
'png': imageHandler,
'jpg': imageHandler,
'jpeg': imageHandler,
'gif': imageHandler,
'mp3': audioHandler,
'ogg': audioHandler,
'wav': audioHandler,
'json': jsonHandler
});
}

部署测试

1. 本地测试

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 本地测试时模拟 FbPlayableAd -->
<script>
if (typeof window.FbPlayableAd === 'undefined') {
window.FbPlayableAd = {
onCTAClick: function() {
console.log('CTA Clicked!');
// 本地测试跳转到商店
window.open('https://play.google.com/store/apps/details?id=com.yourgame');
}
};
}
</script>

2. Facebook 测试

  1. 进入 Facebook 广告管理工具
  2. 创建 Playable 广告
  3. 上传 HTML 文件
  4. 使用预览工具测试

3. 常见问题

问题 解决方案
文件过大 压缩图片、使用更小的音频格式
加载慢 减少首屏资源、使用资源预加载
CTA 不响应 检查 FbPlayableAd.onCTAClick 调用
黑屏 检查资源路径、确保 resMap 正确生成

优化技巧

1. 资源压缩

1
2
3
4
5
6
7
8
9
# 使用 TinyPNG API 压缩图片
import tinify

tinify.key = "YOUR_API_KEY"

def compress_image(input_path, output_path):
source = tinify.from_file(input_path)
source.to_file(output_path)
print(f"Compressed: {input_path}")

2. 代码混淆

1
2
3
4
5
6
7
8
// 使用 UglifyJS 压缩代码
const UglifyJS = require('uglify-js');
const fs = require('fs');

const code = fs.readFileSync('project.js', 'utf8');
const result = UglifyJS.minify(code);

fs.writeFileSync('project.min.js', result.code);

3. 加载优化

1
2
3
4
5
// 首屏资源优先加载
cc.director.preloadScene('PlayableScene', (completedCount, totalCount) => {
const percent = 100 * completedCount / totalCount;
// 更新加载进度
});

性能监控

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
// 性能数据上报
class PlayableAnalytics {
static init() {
this.startTime = Date.now();
this.trackEvent('playable_start');
}

static trackEvent(event, params = {}) {
const data = {
event: event,
timestamp: Date.now(),
duration: Date.now() - this.startTime,
...params
};

console.log('Analytics:', data);

// 实际部署时发送到服务器
// this.sendToServer(data);
}

static onCTAClick() {
this.trackEvent('cta_click', {
game_duration: Date.now() - this.startTime
});

if (typeof FbPlayableAd !== 'undefined') {
FbPlayableAd.onCTAClick();
}
}
}

小结

Playable 广告制作流程:

  1. 开发试玩场景:简化版核心玩法,15-30秒体验
  2. 资源优化:Base64 内嵌,控制文件大小在2MB内
  3. 打包工具:使用 Python 脚本打包成单个 HTML
  4. 集成 CTA:调用 FbPlayableAd.onCTAClick()
  5. 测试部署:本地测试 -> Facebook 预览 -> 正式上线

Playable 广告能让用户在下载前充分体验游戏,对提升广告转化率和用户质量挺有帮助的。