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 调试绘制与可视化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。