《Cocos 游戏开发入门》14.3 经济与掉落表

击杀之后掉什么、掉多少、连掉十次不出稀有怎么办?本节为 mini2d 搭一套掉落系统:用权重表表达概率、上线前算清期望值、用保底机制兜住坏运气,并讲清伪随机与真随机的取舍与可复现的种子 PRNG;末节对照 Cocos Creator 3.8 的 math.randomRange 与随机整数 API。

14.3 经济与掉落表

14.2 我们把技能做成了数据驱动的效果组合,击杀、命中、增益都走同一条管线。但击杀之后掉什么,一直是《方块冲刺》里最随意的部分:Math.random() < 0.1 就掉金币,else 掉空气。这种写法在原型阶段够用,一进入长线运营就出问题——玩家会精确地感觉到「我运气差」,而你连「平均一局掉多少金币」都说不出来。这一节把掉落做成一套可计算、可保底、可复现的系统。末节给出 Cocos Creator 3.8 的对照。

本节解决三个问题:概率怎么表达、期望怎么算、坏运气怎么兜。前两个是经济设计,第三个是玩家体验,三者缺一不可。

14.3.1 固定掉落撑不起长线

掉落系统的演化通常经历三个阶段:

阶段做法问题
固定掉落每个怪物写死掉落物无随机性,几局就腻
独立概率每件物品各自 Math.random() < p可能一件都不掉,也可能全掉;期望难算
权重表一张表按权重抽一次概率可控、期望可算,是主流做法

第二阶段的陷阱在于「独立概率」会让掉落总数不可控。假设一个怪物挂了 5 件物品、每件掉率 0.2,理论上可能 5 件全掉,也可能一件不掉;如果策划想保证「至少掉 1 件」,独立概率做不到。权重表把「抽一次,必出一件」变成默认语义,掉落数量由 rolls 显式控制,期望值因此可以精确计算。

14.3.2 权重表:结构与归一化

权重表的核心思想是:每件物品给一个相对权重,实际概率 = 该权重 / 总权重。权重是相对值,不需要凑成 100,策划改起来更自由。

export interface DropEntry {
  readonly item: string;
  readonly weight: number;
  readonly min?: number;
  readonly max?: number;
}

export interface LootTable {
  readonly id: string;
  readonly rolls: number;
  readonly entries: readonly DropEntry[];
}

export function rollOnce(table: LootTable, rng: () => number): string {
  let total = 0;
  for (const e of table.entries) total += e.weight;
  let r = rng() * total;
  for (const e of table.entries) {
    r -= e.weight;
    if (r < 0) return e.item;
  }
  // 浮点误差兜底:落回最后一项,保证永远有返回值
  return table.entries[table.entries.length - 1].item;
}

这段「累减」写法比「累加前缀和再比较」更省一次遍历,也更不容易在浮点误差下越界。三个必须遵守的约束:权重必须为正(0 或负数会让 total 出错),entries 不能为空(否则最后一行会读到 undefined),rng() 必须返回 [0, 1)(返回 1 会让 r 恰好等于 total,需要兜底)。把这三条写成断言或校验函数,在加载配置时就报错,比等到线上掉不出东西再查划算得多。

export function validateTable(table: LootTable): void {
  if (table.entries.length === 0) throw new Error(`掉落表 ${table.id} 为空`);
  if (table.rolls <= 0) throw new Error(`掉落表 ${table.id} 的 rolls 必须为正`);
  for (const e of table.entries) {
    if (e.weight <= 0) throw new Error(`掉落表 ${table.id} 的 ${e.item} 权重非正`);
  }
}

14.3.3 期望值:上线前先算清楚

权重表最大的好处是期望值可解析计算,不用靠模拟。一件物品的概率是 weight / total,一局掉落的期望件数是 rolls 倍:

export function expectedCount(table: LootTable, item: string): number {
  const total = table.entries.reduce((s, e) => s + e.weight, 0);
  const entry = table.entries.find((e) => e.item === item);
  if (!entry) return 0;
  return (entry.weight / total) * table.rolls;
}

export function expectedValue(table: LootTable, price: (item: string) => number): number {
  const total = table.entries.reduce((s, e) => s + e.weight, 0);
  const perRoll = table.entries.reduce((s, e) => s + (e.weight / total) * price(e.item), 0);
  return perRoll * table.rolls;
}

expectedValue 是经济设计的仪表盘:它把「这张掉落表值多少金币」变成一个数字。策划调完权重,跑一遍这个函数就知道一局的经济产出涨了多少,而不是等玩家在论坛喊「金币贬值了」。把这张表按关卡、按怪物类型都算一遍,加起来就是全局经济期望——这是后面设计商店定价、升级消耗的基准线。

举个具体的表,看期望怎么用:

export const SLIME_LOOT: LootTable = {
  id: 'slime',
  rolls: 2,
  entries: [
    { item: 'gold', weight: 70, min: 5, max: 15 },
    { item: 'herb', weight: 25, min: 1, max: 2 },
    { item: 'gem', weight: 5, min: 1, max: 1 },
  ],
};

总权重 100,rolls = 2。一局期望掉 2 × 0.7 = 1.4 份金币、0.5 份草药、0.1 颗宝石。如果宝石定价 200 金币,那么宝石对期望价值的贡献是 0.1 × 200 = 20 金币/局——这个数字要写进设计文档,而不是拍脑袋。

rolls 决定抽几次,min / max 决定每次抽中后给几个。把两者拼起来就是完整的「掷表」函数:

export interface Drop {
  readonly item: string;
  readonly count: number;
}

export function rollTable(table: LootTable, rng: () => number): Drop[] {
  const out: Drop[] = [];
  for (let i = 0; i < table.rolls; i++) {
    const item = rollOnce(table, rng);
    const entry = table.entries.find((e) => e.item === item);
    if (!entry) continue;
    const lo = entry.min ?? 1;
    const hi = entry.max ?? lo;
    // randomRangeInt 语义:[lo, hi + 1) 取整数,保证 hi 可被抽到
    const count = lo + Math.floor(rng() * (hi - lo + 1));
    out.push({ item, count });
  }
  return out;
}

这里有个极易踩的坑:Math.floor(rng() * (hi - lo + 1)) 里的 + 1 不能省。少写这个 + 1 会让 max 永远抽不到,min: 5, max: 15 实际只会产出 5 到 14。这个 off-by-one 在掉落系统里非常隐蔽——玩家不会立刻察觉,但长期统计里高值段的频率会系统性偏低。写完掉落代码后,用一个固定种子跑一万次、打印各档计数的直方图,是成本最低的验收手段。

export function histogram(table: LootTable, rng: () => number, n: number): Map<string, number> {
  const hist = new Map<string, number>();
  for (let i = 0; i < n; i++) {
    for (const d of rollTable(table, rng)) {
      hist.set(d.item, (hist.get(d.item) ?? 0) + d.count);
    }
  }
  return hist;
}

固定种子跑出来的直方图应当与 expectedValue 解析算出的期望在误差范围内吻合(样本量越大越接近)。解析值与模拟值对不上,八成是权重、rolls 或 + 1 哪一处写错了——这条交叉验证比肉眼读代码可靠得多。

14.3.4 保底机制(pity)

纯权重表的致命伤是方差。5% 的宝石掉率意味着:连续 20 局不掉宝石的概率是 0.95^20 ≈ 36%。三分之一的玩家会经历「我打了二十局什么都没有」,而其中一部分会直接流失。保底机制就是在计数器上做文章——连续 N 次没出稀有,第 N+1 次强制出:

export class PityTracker {
  private threshold: number;
  private counter = new Map<string, number>();

  constructor(threshold: number) {
    this.threshold = threshold;
  }

  roll(table: LootTable, key: string, rng: () => number): string {
    const n = (this.counter.get(key) ?? 0) + 1;
    if (n >= this.threshold) {
      this.counter.set(key, 0);
      return 'gem';
    }
    const item = rollOnce(table, rng);
    this.counter.set(key, item === 'gem' ? 0 : n);
    return item;
  }
}

保底有三个必须想清楚的细节。第一,保底计数器按什么维度累计:按玩家(全局保底)还是按怪物(每次击杀独立)?《方块冲刺》用的是「按玩家 + 按稀有度」,这样玩家能明确感知「再打几把就出」。第二,保底触发后要不要重置:上例里出了宝石就把计数清零,这是「硬保底」;也有「软保底」——接近阈值时逐渐提高掉率,体验更平滑但实现更复杂。第三,保底会不会被滥用:如果计数器是本地存档,玩家可以通过删档重来刷保底,所以关键经济的保底必须放服务端校验(这正是卷四联机篇的话题)。

加了保底之后,实际掉率会高于权重表的标称值。5% 标称 + 20 次硬保底的实际期望大约是 1 / (1/0.05 与 20 的调和),需要重新算一遍经济期望,别让保底悄悄把金币产出抬高了两成。

14.3.5 伪随机与真随机:可复现的 PRNG

Math.random() 是不可复现的:同一个种子、同一段操作,两次运行的结果不同。这在单局游戏里无所谓,但会破坏两件事——确定性回放(第 16 章要录一段输入就能重放整局)与可复现的测试(掉落相关的单测会随机失败)。解决办法是自己实现一个可播种的伪随机数发生器:

export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

mulberry32 只有几行,返回 [0, 1) 的浮点数,且给定种子就完全确定。测试里固定种子,掉落序列就固定,断言可以写死;回放里把种子存进存档,整局的所有随机都能重演。用它替换掉 rollOnce 里的 rng 参数,掉落系统立刻变得可测试、可回放。

除了「可复现」,还有一类体感随机值得知道:真随机的「连中」与「连不中」都太常见,玩家会怀疑「这概率是假的」。业界常见的做法是 PRD(伪随机分布)——每次失败后把下次的触发概率按次数线性抬高,使得长期概率不变但短期更平滑:

export function prdChance(base: number, failedStreak: number): number {
  // 第 n 次尝试的触发概率约为 base * n,上限 1
  return Math.min(1, base * (failedStreak + 1));
}

PRD 常用于暴击、闪避这类高频、玩家能立刻感知的判定;而掉落这种低频、带保底的事件,直接用权重表 + 硬保底就够。《方块冲刺》的取舍是:暴击用 PRD,掉落用权重 + 保底。

14.3.6 Cocos 对照:math.randomRange

Cocos Creator 3.8 的 math 命名空间提供了现成的随机函数。cc.d.ts 里两个签名是 math.randomRange(min: number, max: number): number(返回 [min, max) 的浮点数)与 math.randomRangeInt(min: number, max: number): number(返回 [min, max) 的整数)。注意上界是开区间,写 randomRangeInt(0, 3) 只会得到 0、1、2:

import { _decorator, Component, math } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('LootRoller')
export class LootRoller extends Component {
  @property({ tooltip: '稀有物品基础概率' })
  rareChance = 0.05;

  rollRare(): boolean {
    // [0, 1) 的浮点随机数
    return math.randomRange(0, 1) < this.rareChance;
  }

  rollCount(min: number, max: number): number {
    // 整数随机,注意 max 取不到
    return math.randomRangeInt(min, max);
  }

  rollWeight(weights: number[]): number {
    let total = 0;
    for (const w of weights) total += w;
    let r = math.randomRange(0, total);
    for (let i = 0; i < weights.length; i++) {
      r -= weights[i];
      if (r < 0) return i;
    }
    return weights.length - 1;
  }
}

注意 math.randomRange 底层用的仍是引擎内部的随机源,同样不可播种、不可复现。所以如果工程要做确定性回放或掉落单测,仍然要像 mini2d 那样自带一个 mulberry32,把 math.randomRange 换成注入的 rng。Cocos 侧真正的差异只在加载配置(JsonAsset)与触发时机(组件 update 或事件回调),掉落算法本身与引擎无关。

关注点mini2d 掉落系统Cocos Creator 3.8
基础随机自写 mulberry32(可播种)math.randomRange / math.randomRangeInt
整数随机上界自行约定,含上界与否自己定randomRangeInt 上界取不到
权重抽取rollOnce 累减同上,可复用同一算法
可复现种子进存档需自带 PRNG,引擎随机不可播种
保底PityTracker 计数器同左,但关键经济须服务端校验

三节下来,数值与玩法系统的骨架就齐了:14.1 的结算管线保证顺序确定,14.2 的效果系统让技能可组合、可配置,14.3 的掉落系统让经济可算、可保底、可复现。下一节我们换到工具链视角,看看怎么把这一堆逻辑可视化地调试出来。

阅读导航:上一节:14.2 技能与效果系统:数据驱动 · 下一节:15.1 调试绘制与可视化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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