《Cocos 游戏开发实战》17.3 活动配置与数据看板

本节把《方块冲刺》的运营侧补齐:用一张 activities.json 描述活动与时段,纯逻辑校验时间窗、权重与互斥优先级,运行时下发并本地兜底,再定义一套埋点事件模型与看板四项核心指标,让运营能自己上活动、关活动、看数据,而不必每次改代码发版。文末给出与 17.1/17.2 共用的配置骨架与工程收口。

17.3 活动配置与数据看板

17.1 让发布可控,17.2 让线上可修,这一节让运营可自助。运营的需求其实很朴素:今天开个双倍金币,明天关掉;看看昨天有多少人玩到第 10 关。如果每次都要程序员改代码发版,运营就永远在排队。

本节把《方块冲刺》推进到「运营自助」:用 configs/activities.json 描述活动,纯逻辑校验时间窗与权重,运行时下发并本地兜底,再定义埋点事件模型和看板四项核心指标。收口时工程里会多出 assets/scripts/platform/liveops.ts、assets/configs/activities.json 和一份埋点事件表。

与站内 game/client/ 下 LiveOps 相关文章的分工:那些文章讲运营策略与活动设计,本节讲工程侧落点——配置长什么样、怎么校验、埋点打在哪、看板看哪几个数。

17.3.1 运营要的三件事

把运营的诉求拆到工程层,其实只有三件事:

  1. 上活动 / 关活动:不改代码、不发版,改一份配置就生效。
  2. 分人群:新用户和老用户看到的可能不一样,要能按条件命中。
  3. 看数据:活动开了以后,得知道有没有用。

对应到工程就是:配置表 + 下发通道 + 埋点。三件事缺一不可——只做配置表没有下发,运营还得找你;只做埋点没有配置,运营看着数据却没法调。

17.3.2 活动配置表的结构

先定义结构。活动配置是纯数据,用 L1 的类型 + 校验,可以脱离引擎直接单测:

// core/activity.ts —— 与引擎无关,可 vitest 直接测
export type ActivityKind = 'double_coin' | 'discount' | 'login_bonus';

export interface ActivityConfig {
  id: string;
  kind: ActivityKind;
  startAt: number;
  endAt: number;
  weight: number;
  enabled: boolean;
}

export interface ActivityIssue {
  id: string;
  reason: string;
}

export function validateActivities(
  list: ActivityConfig[],
  now: number,
): ActivityIssue[] {
  const issues: ActivityIssue[] = [];
  const ids = new Set<string>();
  for (const a of list) {
    if (ids.has(a.id)) issues.push({ id: a.id, reason: 'id 重复' });
    ids.add(a.id);
    if (a.startAt >= a.endAt) {
      issues.push({ id: a.id, reason: '时间窗非法:startAt >= endAt' });
    }
    if (a.weight < 0 || a.weight > 100) {
      issues.push({ id: a.id, reason: `weight 越界:${a.weight}` });
    }
    if (a.endAt < now && a.enabled) {
      issues.push({ id: a.id, reason: '已过期但仍 enabled' });
    }
  }
  return issues;
}

校验规则里最容易被忽略的是最后一条:过期但没关的活动。运营常常只配 endAt,忘了改 enabled,结果活动窗口过了还在跑。把这类问题在校验期就报出来,比线上发现强。

weight 的含义是「同类型活动的相对权重」。比如同时开两个 double_coin,权重 70 和 30 就是 70% 的用户进第一个。权重不是百分比,只是相对值——所以校验只保证非负,不要求求和为 100。

17.3.3 下发与本地兜底

配置的下发通道复用的就是 17.1 那套远端拉取,只是内容换成活动表。运行时组件读取并缓存到本地:

import { _decorator, Component, JsonAsset, assetManager, sys } from 'cc';
import { ActivityConfig, validateActivities } from '../core/activity';

const { ccclass, property } = _decorator;

@ccclass('LiveOps')
export class LiveOps extends Component {
  @property
  configUrl = '';

  private _activities: ActivityConfig[] = [];
  private _version = 0;

  async refresh(): Promise<void> {
    const url = `${this.configUrl}?t=${Date.now()}`;
    await new Promise<void>((resolve) => {
      assetManager.loadRemote<JsonAsset>(url, (err, asset) => {
        if (!err && asset) {
          const data = asset.json as Record<string, unknown> | null;
          const list = (data?.activities ?? []) as ActivityConfig[];
          const now = Date.now();
          const issues = validateActivities(list, now);
          if (issues.length === 0) {
            this._activities = list;
            this._version = (data?.version as number) ?? 0;
            sys.localStorage.setItem('liveops:activities', JSON.stringify(list));
          } else {
            console.warn('活动配置校验未通过', issues);
          }
        }
        resolve();
      });
    });
  }

  activeAt(now: number): ActivityConfig | null {
    const hit = this._activities.filter(
      (a) => a.enabled && a.startAt <= now && now < a.endAt,
    );
    return hit.length > 0 ? hit[0] : null;
  }
}

三处设计:

  • 校验不通过就不落地,保留上一次的配置。宁可跑旧活动,也不要跑一份结构错的活动。
  • 写 sys.localStorage 做本地缓存。键名 liveops:activities 与 §C 的存档约定一致——H5 端 sys.localStorage 等价于 window.localStorage,微信端由引擎适配,三端统一。
  • _version 用于「配置版本随每次加载校验」,这是 17.2 末尾提到的、规避「用户不重启导致新旧配置混用」的手段。

17.3.4 互斥与优先级:同时开多个活动怎么办

运营常常同时开两三个活动,问题立刻来了:双倍金币和登录奖励同时命中,玩家一次拿到两份奖励,数值崩了。解决方式有两层——互斥与优先级。

  • 互斥:同一 kind 的活动在同一时刻只能有一个生效。上面 activeAt 里 hit[0] 取第一个,就是最简单的互斥实现。
  • 优先级:不同 kind 之间若会叠加,用 weight 或显式的 priority 决定谁盖过谁。

把这条写进校验:同 kind 且时间窗重叠的活动,必须由权重分出主次,否则报错。这样运营配错时,是本地校验先报,而不是线上数值爆炸:

// core/activity.ts(续)—— 同 kind 时间窗重叠检测
export function findOverlaps(list: ActivityConfig[]): ActivityIssue[] {
  const issues: ActivityIssue[] = [];
  for (let i = 0; i < list.length; i += 1) {
    for (let j = i + 1; j < list.length; j += 1) {
      const a = list[i];
      const b = list[j];
      if (a.kind !== b.kind) continue;
      const overlap = a.startAt < b.endAt && b.startAt < a.endAt;
      if (overlap && a.weight === b.weight) {
        issues.push({ id: a.id, reason: `与 ${b.id} 重叠且权重相同` });
      }
    }
  }
  return issues;
}

这段是 O(n²),但活动数量通常在 10 个以内,完全够用。不要为运营配置做过度优化——真正的问题是配置写错,不是比对慢。

17.3.5 埋点事件模型

看板的数据来自埋点。埋点最容易失控的地方是事件名和参数随手写,三个月后没人看得懂。所以先定一份事件表,代码里只允许用表里的常量:

事件名触发时机关键参数
game_start进入首局channel、version、device
level_start开始某关level_id、activity_id
level_end结算某关level_id、result、duration_ms、coins
activity_enter活动生效被读取activity_id、kind
purchase内购成功sku、amount、currency

事件名的命名约定:全小写 + 下划线,动词在后。level_end 而不是 endLevel 或 LevelEnd——跨端跨语言时,小写下划线最不容易出错。

参数里的 duration_ms 显式带单位后缀。这一条看着啰嗦,但没有单位的数字是埋点里最大的歧义源——曾经有个项目 duration 一半是毫秒一半是秒,看板上的曲线根本没法读。

17.3.6 看板的四项核心指标

埋点数据上来之后,看板只需要盯四组数,多了就是噪音:

指标定义看什么
DAU当日去重 game_start 用户数大盘趋势
关卡漏斗第 N 关 level_start / 第 N-1 关 level_end卡点在哪一关
活动参与率activity_enter / game_start活动有没有被感知到
ARPUpurchase 金额 / DAU变现效率

其中关卡漏斗是《方块冲刺》最有用的一张图:它直接告诉你玩家卡在第几关,运营据此调 configs/levels.json 里那一关的难度参数,改完热更生效。运营发现卡点 → 改配置 → 热更 → 再看漏斗,这条闭环不需要程序员介入,这正是本节的目标。

活动参与率低通常不是活动本身的问题,而是入口没被看到。如果 activity_enter / game_start 低于 5%,先查活动入口的 UI 层级,而不是怀疑活动设计。

17.3.7 在编辑器里看什么

本节涉及一份配置资源,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

打开 assets/configs/activities.json,属性面板会显示它是一个 JsonAsset,json 字段内容形如:

{
  "version": 12,
  "activities": [
    {
      "id": "double_coin_weekend",
      "kind": "double_coin",
      "startAt": 1790000000000,
      "endAt": 1790200000000,
      "weight": 100,
      "enabled": true
    }
  ]
}

在层级管理器里,常驻节点上的 LiveOps 组件会显示 configUrl 字段,指向 https://cdn.example.com/blockdash/config/activities.json。

这里和 17.1 的 remote.json 有一个共同点:assets/configs/ 下的这份是本地兜底,远端拉不到时用。所以它的 version 必须永远小于等于线上版本,否则用户会被旧活动覆盖新活动。把「本地配置保守」写进团队约定,能省掉一大类线上怪问题。

17.3.8 运营配置上线自检

  • 活动 id 全局唯一,startAt < endAt,weight 在 [0, 100]
  • 已过期活动的 enabled 已置 false,没有「僵尸活动」
  • 同 kind 时间窗重叠的活动,权重已分出主次
  • 本地 activities.json 的 version 不大于线上版本
  • 五类埋点事件名与参数名取自事件表,没有现场命名
  • 看板已能按 activity_id 分组,活动开没开一测便知

第 6 条是闭环的关键:活动上线后第一件事不是看流水,而是确认埋点能按 activity_id 拆开。拆不开,活动效果就永远是个黑盒。

17.3.9 工程收口

这一节做完,工程里多了这些东西:

  • assets/scripts/core/activity.ts:活动类型与校验,纯逻辑可单测。
  • assets/scripts/platform/liveops.ts:活动配置下发与本地兜底。
  • assets/configs/activities.json:本地兜底配置。
  • 埋点事件表:五类事件、固定参数名与单位后缀。

到这里,第 17 章「上线与运营」就闭环了:17.1 控制发布、17.2 修复线上、17.3 支撑运营。三节共用同一套「远端配置 + 本地兜底 + 纯逻辑校验」的骨架——这不是巧合,而是卷二想传达的一个工程直觉:凡是运营会频繁改的东西,都该是数据、都该可校验、都该有兜底。

下一章是收尾。第 18 章把整卷的检查清单、踩过的坑、以及通向高级卷的路线一次性交代清楚。下一节先把发布检查清单补齐——它要能和第 1.1 节定的验收标准逐条对上。

阅读导航:上一节:17.2 热更与线上修复 · 下一节:18.1 发布检查清单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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