《Cocos 游戏开发实战》12.2 广告与激励视频

激励视频是《方块冲刺》最主要的变现方式,也是体验最容易翻车的地方。本节先规划广告位,再给出「创建一次、加载、展示、onClose 结算」的完整时序,写一个带状态机与并发锁的 AdManager,保证奖励只发一次,最后处理无广告填充时的降级策略与激励弹窗预制体。

12.2 广告与激励视频

《方块冲刺》是免费游戏,变现靠两条腿:广告和内购(下一节)。其中激励视频是主力——它不打断玩家,而是「你想继续跑下去吗?看个广告换一次复活」。设计得好,玩家觉得是福利;设计得差,玩家觉得被勒索。这一节把技术骨架搭对,把「奖励只发一次」「无广告时怎么办」这两个最容易出错的地方讲透。

12.2.1 广告位规划

先把「放哪些广告」定下来,再谈怎么实现:

类型触发点收益量级体验代价本项目取舍
激励视频复活、双倍金币、领道具高低(玩家主动)做
插屏广告关卡切换、返回主菜单中高(打断)只做冷启动一次
Banner常驻 HUD 底部低中(占屏、易误点)不做

原则只有一条:广告要么由玩家主动触发,要么出现在自然的停顿点。跑酷游戏最忌讳在冲刺中途弹广告——那会直接毁掉手感。所以本项目的插屏只在「冷启动加载完、进入主菜单前」展示一次,且带频控(同一玩家每天最多一次)。

激励视频的「触发点」也要精选。同样是看广告,给玩家的感受差别很大:

  • 复活:最自然的点。玩家刚失败、有继续的欲望,转化率最高。
  • 双倍结算:结算页给一次翻倍机会,几乎无干扰,玩家很容易点。
  • 免费道具:金币不够时给一次免费领取,把「看广告」变成「省金币」。
  • 强行观看:任何「不看广告就不能继续」的设计都不要做——那不是激励,是胁迫。

一个实用的经验值:一局游戏里最多出现两次激励视频入口(复活 + 结算翻倍),再多就会让玩家感到被反复推销。

12.2.2 激励视频的接口约定与时序

微信小游戏的激励视频由 wx.createRewardedVideoAd 创建,生命周期有四个状态:

  1. 创建:createRewardedVideoAd({ adUnitId }),一个 adUnitId 只需创建一次实例。
  2. 加载:ad.load() 预拉取广告素材,返回 Promise。
  3. 展示:ad.show(),玩家看完后触发 onClose。
  4. 结算:onClose 回调里的 res.isEnded 为 true 表示看完了,否则是中途关闭。

几个必须遵守的约定:onClose / onError 只注册一次(它们是持续监听,重复注册会累积回调);show() 失败时要先 load() 再重试一次;每次播放结束后要重新 load(),否则第二次播放会拿到空广告。

onError 会带回错误码,不同码的处理方式不同,别一律当成失败:

错误码含义典型场景处理
无广告填充该地区/时段没广告可投降级,走免费奖励分支
拉取超时弱网重试一次,仍失败则降级
实例已销毁切后台后实例失效重新 createRewardedVideoAd
参数错误adUnitId 配错开发期直接抛错,别静默吞掉

前两类可以自动降级,第四类必须在开发期就暴露——adUnitId 填错如果被静默处理,上线后才发现「一分钱广告收入都没有」,代价太大。

12.2.3 结算只发一次

激励视频最经典的 bug 是「看一次广告拿了三次奖励」。原因通常是:onClose 注册了多次、或者 show() 失败重试时把同一局算了两遍。

解法是给播放过程加一个单飞锁:同一时刻只允许一局在播,结算时先把锁清空、再回调,且回调只执行一次。下面的 AdManager 就用一个 pending 变量实现这件事——它既是「当前这一局的结算函数」,也是「是否有局在播」的标志。

还有一个容易被忽略的时序问题:onClose 回调是异步的,show() 的 Promise 在广告开始播放时就 resolve 了,而不是播放结束时。所以不能在 await show() 之后立刻发奖励——那时玩家可能才刚看到广告第一帧。发奖励的唯一正确时机是 onClose 回调。这一点在真机上测一次就会暴露,但在桌面模拟器上往往看不出来。

12.2.4 AdManager(L2)

// assets/scripts/platform/ads.ts
import { sys } from 'cc';

export type AdResult = 'completed' | 'skipped' | 'failed';

interface RewardedVideoAd {
  load(): Promise<void>;
  show(): Promise<void>;
  onClose(cb: (res?: { isEnded: boolean }) => void): void;
  onError(cb: (err: { errCode: number; errMsg: string }) => void): void;
}

interface WeChatAdApi {
  createRewardedVideoAd(opts: { adUnitId: string }): RewardedVideoAd;
}

function getWeChat(): WeChatAdApi | null {
  const g = globalThis as { wx?: WeChatAdApi };
  return g.wx ?? null;
}

type Resolver = (result: AdResult) => void;

let ad: RewardedVideoAd | null = null;
let pending: Resolver | null = null;

function settle(result: AdResult): void {
  const resolver = pending;
  pending = null;
  resolver?.(result);
}

export function prepareRewardedVideo(adUnitId: string): void {
  if (sys.platform !== sys.Platform.WECHAT_GAME) return;
  const wx = getWeChat();
  if (!wx || ad) return;
  ad = wx.createRewardedVideoAd({ adUnitId });
  // 回调只注册一次;之后每次播放都路由到 pending
  ad.onClose((res) => {
    const done = !res || res.isEnded;
    settle(done ? 'completed' : 'skipped');
  });
  ad.onError(() => settle('failed'));
}

export function playRewardedVideo(): Promise<AdResult> {
  const instance = ad;
  if (!instance || pending) return Promise.resolve('failed');
  return new Promise<AdResult>((resolve) => {
    pending = resolve;
    instance.show().catch(() => {
      // show 失败:先 load 再重试一次,仍失败则判定 failed
      instance
        .load()
        .then(() => instance.show())
        .catch(() => settle('failed'));
    });
  });
}

prepareRewardedVideo 在启动时调一次,playRewardedVideo 每次要看广告时调。注意 !instance || pending 这个前置判断:上一局还没结算完就再次调用,直接返回 failed,而不是排队——广告是玩家主动点的,并发请求本来就该拒绝。settle() 先把 pending 置空再回调,保证无论 onClose 和 onError 谁先到,结算都只发生一次。

12.2.5 预加载与无广告降级

激励视频能不能播,取决于有没有广告填充——这是平台侧决定的,你无法保证。所以调用方必须处理 failed 分支。

推荐做法是体验优先的降级:玩家点了「看广告复活」,如果广告拉不到,就直接给他一次免费复活(或给一半奖励),并提示「本次免费」。理由是:玩家点这个按钮的意图是「我想继续玩」,卡在这里只会让他退出游戏;一次免费复活的成本,远低于一次糟糕体验带来的流失。

不同结果对应的处理,用一张表定死:

AdResult触发条件奖励玩家提示
completedonClose 且 isEnded全额无(直接生效)
skippedonClose 但未看完不发「完整观看才能复活」
failedonError / show 失败全额(免费)「本次免费复活」

skipped 与 failed 的区别很重要:前者是玩家主动放弃,不补;后者是平台侧没广告,要补。把两者混为一谈,要么让玩家白白损失、要么被刷免费奖励。

预加载则相反,要主动:进入关卡时就把 prepareRewardedVideo 和一次 load() 做掉,等玩家真点按钮时广告已就绪,点下去立刻出画。冷启动时预加载还能和首屏加载并行,不额外增加等待。

一个时序细节:load() 是异步的,别在按钮回调里现加载。玩家点「看广告」时若才开始 load(),会看到明显的转圈等待,转化率直接掉。正确做法是「空闲时预加载、需要时直接 show()」。切后台再回前台时,广告实例可能失效,这时补一次 load() 即可——onError 里的「实例已销毁」分支就是为它准备的。

// assets/scripts/game/RewardGate.ts
import { _decorator, Component, warn } from 'cc';

const { ccclass } = _decorator;

declare function playRewardedVideo(): Promise<'completed' | 'skipped' | 'failed'>;
declare function grantRevive(): void;

@ccclass('RewardGate')
export class RewardGate extends Component {
  async onReviveClicked(): Promise<void> {
    const result = await playRewardedVideo();
    if (result === 'completed') {
      grantRevive();
      return;
    }
    if (result === 'failed') {
      warn('[ad] 无广告填充,本次免费复活');
      grantRevive();
      return;
    }
    // skipped:玩家中途关闭,不发奖励
  }
}

RewardGate 把「看广告 → 判定 → 发货」这段收在一个组件里。它只依赖 playRewardedVideo 的返回类型,不关心广告从哪来——单测时把 playRewardedVideo 换成返回固定值的假函数即可。

12.2.6 广告埋点与频控

广告要不要调整,靠数据说话。至少要埋五个事件:ad_request(玩家点了看广告)、ad_show(广告真的出画了)、ad_complete(看完)、ad_skip(中途关闭)、ad_fail(失败及原因)。它们能算出两个关键指标:填充率 = ad_show / ad_request,完播率 = ad_complete / ad_show。填充率低说明降级分支要做得更顺滑,完播率低说明奖励给得不够、玩家不愿意看完。

插屏广告还要频控,避免骚扰。频控逻辑是纯状态运算,可以直接单测:

// assets/scripts/core/frequency-cap.ts
export interface CapState {
  count: number;
  windowStart: number;
}

const DAY_MS = 24 * 60 * 60 * 1000;

export function allowOncePerDay(
  state: CapState,
  now: number,
): { ok: boolean; next: CapState } {
  if (now - state.windowStart >= DAY_MS) {
    return { ok: true, next: { count: 1, windowStart: now } };
  }
  if (state.count >= 1) {
    return { ok: false, next: state };
  }
  return { ok: true, next: { count: state.count + 1, windowStart: state.windowStart } };
}

调用方把 CapState 存进 11.1 的存档,每次想弹插屏前问一次 allowOncePerDay,返回 ok 才弹、并把 next 写回。注意用设备本地时间会被人调表绕过,但对插屏这种低价值场景,本地时间足够;真正涉及收益的频控(比如「每天领一次金币」)必须由服务端按服务器时间判定。

埋点事件要带上「广告位标识」和「场景」两个维度,否则数据到手也没法分析——只知道「失败了 3000 次」,却不知道是复活位还是结算位失败。建议每个广告位分配一个固定字符串(如 revive / double_coins),作为 ad_request 的必带参数。

这些事件走第 16 章要建的埋点通道上报,本节只定义事件名与参数形状。不要在广告回调里直接发网络请求——那会阻塞结算、也可能在弱网下丢事件;正确做法是先塞进内存队列,由统一的埋点模块批量上报。

12.2.7 激励弹窗预制体

复活弹窗 RewardDialog.prefab 放在 assets/prefabs/ 下。以下为它的资源结构摘录。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

{
  "__type__": "cc.Prefab",
  "_name": "RewardDialog",
  "data": { "__id__": 1 },
  "optimizationPolicy": 0,
  "persistent": false
}

在编辑器里双击打开,根节点 RewardDialog 挂 UITransform + Widget(四边对齐、BlockInputEvents 拦截穿透点击),子节点有 Panel(Sprite 背景)、Title(Label,_string 为「继续冲刺?」)、Desc(Label,_string 为「看视频复活一次」)、BtnWatch(Button + 子节点 Label)与 BtnGiveUp(Button)。选中 BtnWatch,属性检查器的 Button 组件 Click Events 列表里应有一条:Target 指向挂 RewardGate 的节点、Component 为 RewardGate、Handler 为 onReviveClicked。这条绑定是序列化在预制体里的,改方法名就会让绑定失效——重命名方法后记得回编辑器重绑。

弹窗的文案建议全部走 11.3 的 I18nLabel:Title 与 Desc 的 Label 旁边各挂一个 I18nLabel,key 分别填 dialog.revive.title 与 dialog.revive.desc。这样序列化里存的中文只是「编辑器预览」,运行时会被当前语言覆盖,中文和英文共用同一套预制体。

12.2.8 收口

本章新增 assets/scripts/platform/ads.ts、assets/scripts/game/RewardGate.ts、assets/scripts/core/frequency-cap.ts 与 assets/prefabs/RewardDialog.prefab。可配置项:adUnitId(各平台一个,建议放 configs/ 按环境区分)、插屏频控次数。下一节讲变现的另一条腿——内购,以及绕不开的合规问题。

阅读导航:上一节:12.1 登录与分享 · 下一节:12.3 内购与合规 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画